From 52892482e4718e31fb641f6d60fa39f41f969bbe Mon Sep 17 00:00:00 2001 From: sauravniraula Date: Sun, 20 Jul 2025 18:45:44 +0545 Subject: [PATCH] fix(nextjs): implements working screenshot logic, perf(nextjs): process all slides in parallel --- servers/fastapi/utils/llm_provider.py | 2 +- .../pdf-maker/PdfMakerPage.tsx | 2 +- .../api/presentation_to_pptx_model/route.ts | 305 +++++++----------- servers/nextjs/types/element_attibutes.ts | 4 + servers/nextjs/utils/pptx_models_utils.ts | 17 +- 5 files changed, 122 insertions(+), 208 deletions(-) diff --git a/servers/fastapi/utils/llm_provider.py b/servers/fastapi/utils/llm_provider.py index c5510d79..f4e801ea 100644 --- a/servers/fastapi/utils/llm_provider.py +++ b/servers/fastapi/utils/llm_provider.py @@ -77,7 +77,7 @@ def get_llm_api_key(): def get_llm_client(): - client = =AsyncOpenAI( + client = AsyncOpenAI( base_url=get_model_base_url(), api_key=get_llm_api_key(), ) diff --git a/servers/nextjs/app/(presentation-generator)/pdf-maker/PdfMakerPage.tsx b/servers/nextjs/app/(presentation-generator)/pdf-maker/PdfMakerPage.tsx index 9c63e419..81197127 100644 --- a/servers/nextjs/app/(presentation-generator)/pdf-maker/PdfMakerPage.tsx +++ b/servers/nextjs/app/(presentation-generator)/pdf-maker/PdfMakerPage.tsx @@ -76,6 +76,7 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
@@ -102,7 +103,6 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => { presentationData.slides.length > 0 && presentationData.slides.map((slide: any, index: number) => (
- {renderSlideContent(slide, false)}
))} 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 eb51b36c..704bdf46 100644 --- a/servers/nextjs/app/api/presentation_to_pptx_model/route.ts +++ b/servers/nextjs/app/api/presentation_to_pptx_model/route.ts @@ -1,15 +1,13 @@ import { ApiError } from "@/models/errors"; import { NextRequest, NextResponse } from "next/server"; -import puppeteer, { Browser, ElementHandle } from "puppeteer"; +import puppeteer, { Browser, ElementHandle, Page } 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"; -import sharp from "sharp"; +import { v4 as uuidv4 } from 'uuid'; -// Interface for getAllChildElementsAttributes function arguments interface GetAllChildElementsAttributesArgs { element: ElementHandle; rootRect?: { left: number; top: number; width: number; height: number } | null; @@ -23,41 +21,27 @@ interface GetAllChildElementsAttributesArgs { export async function GET(request: NextRequest) { let browser: Browser | null = null; + let page: Page | null = null; + try { const id = await getPresentationId(request); - browser = await puppeteer.launch({ - headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox'] - }); + [browser, page] = await getBrowserAndPage(id); + const screenshotsDir = getScreenshotsDir(); - // Ensure screenshots directory exists - const tempDir = process.env.TEMP_DIRECTORY; - if (!tempDir) { - console.warn('TEMP_DIRECTORY environment variable not set, skipping screenshot'); - return undefined; - } - const screenshotsDir = path.join(tempDir, 'screenshots'); - if (!fs.existsSync(screenshotsDir)) { - fs.mkdirSync(screenshotsDir, { recursive: true }); - } - - const slides = await getSlides(browser, id); + const slides = await getSlides(page); const slides_attributes = await getSlidesAttributes(slides, screenshotsDir); - const slides_pptx_models = convertElementAttributesToPptxSlides(slides_attributes.elements, slides_attributes.backgroundColors); + await postProcessSlidesAttributes(slides_attributes, screenshotsDir); + const slides_pptx_models = convertElementAttributesToPptxSlides(slides_attributes); const presentation_pptx_model: PptxPresentationModel = { slides: slides_pptx_models, }; - if (browser) { - await browser.close(); - } + await closeBrowserAndPage(browser, page); return NextResponse.json(presentation_pptx_model); } catch (error: any) { console.error(error); - if (browser) { - await browser.close(); - } + await closeBrowserAndPage(browser, page); if (error instanceof ApiError) { return NextResponse.json(error, { status: 400 }); } @@ -73,42 +57,19 @@ async function getPresentationId(request: NextRequest) { return id; } -async function getSlidesAttributes(slides: ElementHandle[], screenshotsDir: string) { - 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, screenshotsDir }); - slideResults.push(result); - } +async function getBrowserAndPage(id: string): Promise<[Browser, Page]> { + const browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu', + '--disable-web-security', + '--window-size=1920,1080' + ], + }); - const elements = slideResults.map(result => result.elements); - const backgroundColors = slideResults.map(result => result.backgroundColor); - - return { - elements, - backgroundColors - }; -} - - -async function getSlides(browser: Browser, id: string) { - const slides_wrapper = await getSlidesWrapper(browser, id); - const slides = await slides_wrapper.$$(":scope > div > div"); - return slides; -} - -async function getSlidesWrapper(browser: Browser, id: string): Promise> { - const page = await getPresentationPage(browser, id); - const slides_wrapper = await page.$("#presentation-slides-wrapper"); - if (!slides_wrapper) { - throw new ApiError("Presentation slides not found"); - } - return slides_wrapper; -} - - -async function getPresentationPage(browser: Browser, id: string) { const page = await browser.newPage(); page.on('console', (msg) => { @@ -118,16 +79,82 @@ async function getPresentationPage(browser: Browser, id: string) { }); await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 }); - await page.goto(`http://localhost/presentation?id=${id}`, { + await page.goto(`http://localhost/pdf-maker?id=${id}`, { waitUntil: "networkidle0", timeout: 60000, }); - return page; + return [browser, page]; +} + +async function closeBrowserAndPage(browser: Browser | null, page: Page | null) { + await page?.close(); + await browser?.close(); +} + +function getScreenshotsDir() { + const tempDir = process.env.TEMP_DIRECTORY; + if (!tempDir) { + console.warn('TEMP_DIRECTORY environment variable not set, skipping screenshot'); + throw new ApiError('TEMP_DIRECTORY environment variable not set'); + } + const screenshotsDir = path.join(tempDir, 'screenshots'); + if (!fs.existsSync(screenshotsDir)) { + fs.mkdirSync(screenshotsDir, { recursive: true }); + } + return screenshotsDir; +} + +async function postProcessSlidesAttributes(slidesAttributes: SlideAttributesResult[], screenshotsDir: string) { + for (const slideAttributes of slidesAttributes) { + for (const element of slideAttributes.elements) { + if (element.should_screenshot) { + const screenshotPath = await screenshotElement(element, screenshotsDir); + element.imageSrc = screenshotPath; + element.should_screenshot = false; + element.element = undefined; + } + } + } +} + +async function screenshotElement(element: ElementAttributes, screenshotsDir: string) { + console.log('Taking screenshot of', element.tagName); + const screenshotPath = path.join(screenshotsDir, `${uuidv4()}.png`) as `${string}.png`; + const screenshot = await element.element?.screenshot({ path: screenshotPath }); + if (!screenshot) { + throw new ApiError("Failed to screenshot element"); + } + return screenshotPath; } +async function getSlidesAttributes(slides: ElementHandle[], screenshotsDir: string): Promise { + const slideAttributes = await Promise.all(slides.map(async (slide) => { + return await getAllChildElementsAttributes({ element: slide, screenshotsDir }); + })); + + return slideAttributes; +} + + +async function getSlides(page: Page) { + const slides_wrapper = await getSlidesWrapper(page); + const slides = await slides_wrapper.$$(":scope > div > div"); + return slides; +} + +async function getSlidesWrapper(page: Page): Promise> { + const slides_wrapper = await page.$("#presentation-slides-wrapper"); + if (!slides_wrapper) { + throw new ApiError("Presentation slides not found"); + } + return slides_wrapper; +} + + + + async function getAllChildElementsAttributes({ element, rootRect = null, depth = 0, inheritedFont, inheritedBackground, inheritedBorderRadius, screenshotsDir }: GetAllChildElementsAttributesArgs): Promise { - // Get rootRect if not provided (first call) const currentRootRect = rootRect || await element.evaluate((el) => { const rect = el.getBoundingClientRect(); return { @@ -138,67 +165,23 @@ 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 - }; - - // // Get basic attributes for the element - // const attributes = await getElementAttributes(element); - // // Take screenshot of SVG/canvas/table element with accurate colors and opacity - // const screenshotPath = await takeElementScreenshot(element, screenshotsDir); - - // // 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 these elements - // return { - // elements: [attributes], - // backgroundColor: undefined - // }; - } - - // Get direct children only (not all descendants) const directChildElementHandles = await element.$$(':scope > *'); const allResults: { attributes: ElementAttributes; depth: number }[] = []; - // Process direct children recursively for (const childElementHandle of directChildElementHandles) { - // Get attributes for current child const attributes = await getElementAttributes(childElementHandle); - // Apply inherited font only on elements that have direct text if (inheritedFont && !attributes.font && attributes.innerText && attributes.innerText.trim().length > 0) { attributes.font = inheritedFont; } - // Apply inherited background only on elements that have shadow if (inheritedBackground && !attributes.background && attributes.shadow) { attributes.background = inheritedBackground; } - // Apply inherited border radius if element doesn't have it if (inheritedBorderRadius && !attributes.borderRadius) { attributes.borderRadius = inheritedBorderRadius; } - // Adjust position relative to root if (attributes.position && attributes.position.left !== undefined && attributes.position.top !== undefined) { attributes.position = { left: attributes.position.left - currentRootRect.left, @@ -208,10 +191,18 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth = }; } - // Add current child to results + if (attributes.tagName === 'svg' || attributes.tagName === 'canvas' || attributes.tagName === 'table') { + attributes.should_screenshot = true; + attributes.element = childElementHandle; + } + allResults.push({ attributes, depth }); - // Recursively process children of this child + //? If the element is a svg, canvas, or table, we don't need to go deeper + if (attributes.should_screenshot) { + break; + } + const childResults = await getAllChildElementsAttributes({ element: childElementHandle, rootRect: currentRootRect, @@ -224,7 +215,6 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth = allResults.push(...childResults.elements.map(attr => ({ attributes: attr, depth: depth + 1 }))); } - // Find background color from elements with root position (only in first call) let backgroundColor: string | undefined; if (!rootRect) { const elementsWithRootPosition = allResults.filter(({ attributes }) => { @@ -243,13 +233,15 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth = } } - // Filter results (only in first call) const filteredResults = !rootRect ? allResults.filter(({ attributes }) => { const hasBackground = attributes.background && attributes.background.color; const hasBorder = attributes.border && attributes.border.color; const hasShadow = attributes.shadow && attributes.shadow.color; const hasText = attributes.innerText && attributes.innerText.trim().length > 0; const hasImage = attributes.imageSrc; + const isSvg = attributes.tagName === 'svg'; + const isCanvas = attributes.tagName === 'canvas'; + const isTable = attributes.tagName === 'table'; const isRootPosition = attributes.position && attributes.position.left === 0 && @@ -257,7 +249,7 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth = attributes.position.width === currentRootRect.width && attributes.position.height === currentRootRect.height; - const hasOtherProperties = hasBackground || hasBorder || hasShadow || hasText || hasImage; + const hasOtherProperties = hasBackground || hasBorder || hasShadow || hasText || hasImage || isSvg || isCanvas || isTable; return hasOtherProperties && !isRootPosition; }) : allResults; @@ -274,7 +266,6 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth = return zIndexB - zIndexA; }) .map(({ attributes }) => { - // Set background color to backgroundColor for elements that have shadow but no background color if (attributes.shadow && attributes.shadow.color && (!attributes.background || !attributes.background.color) && backgroundColor) { attributes.background = { color: backgroundColor, @@ -416,8 +407,6 @@ async function getElementAttributes(element: ElementHandle): Promise): Promise): Promise): Promise): Promise): Promise): Promise 0) { const shadowColor = colorParts.join(' '); @@ -509,34 +489,28 @@ async function getElementAttributes(element: ElementHandle): Promise value !== 0); - // Calculate a score for this shadow (higher is better) let shadowScore = 0; if (hasNonZeroValues) { - // Count non-zero numeric values shadowScore += numericParts.filter(value => value !== 0).length; } if (hasVisibleColor) { - shadowScore += 2; // Bonus for visible color + shadowScore += 2; } - // Select this shadow if it has a better score if ((hasNonZeroValues || hasVisibleColor) && shadowScore > bestShadowScore) { selectedShadow = shadowStr; bestShadowScore = shadowScore; } } - // If no meaningful shadow found, use the first one if (!selectedShadow && shadows.length > 0) { selectedShadow = shadows[0]; } if (selectedShadow) { - // Parse the selected shadow const shadowParts = selectedShadow.split(' '); const numericParts: number[] = []; const colorParts: string[] = []; @@ -544,7 +518,6 @@ async function getElementAttributes(element: ElementHandle): Promise): Promise): Promise= 2) { const offsetX = numericParts[0]; const offsetY = numericParts[1]; const blurRadius = numericParts.length >= 3 ? numericParts[2] : 0; const spreadRadius = numericParts.length >= 4 ? numericParts[3] : 0; - // Handle color - it can be anywhere in the parts let shadowColor = 'rgba(0, 0, 0, 0.3)'; // default color if (colorParts.length > 0) { shadowColor = colorParts.join(' '); @@ -602,7 +569,6 @@ async function getElementAttributes(element: ElementHandle): Promise 0 || spreadRadius !== 0 || (shadowColorResult.hex && shadowColorResult.hex !== '000000' && shadowColorResult.opacity !== 0); @@ -650,24 +616,19 @@ async function getElementAttributes(element: ElementHandle): Promise singleLineHeight * 2; // Allow some tolerance + const hasTextWrapping = htmlEl.offsetHeight > singleLineHeight * 2; 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)) { @@ -804,53 +765,3 @@ async function getElementAttributes(element: ElementHandle): Promise, screenshotsDir: string): Promise { - try { - // Check element visibility and dimensions - const elementInfo = await element.evaluate((el) => { - const rect = el.getBoundingClientRect(); - const styles = window.getComputedStyle(el); - - // Check if element is visible - const isVisible = styles.visibility !== 'hidden' && - styles.display !== 'none' && - styles.opacity !== '0'; - - if (!isVisible || rect.width <= 0 || rect.height <= 0) { - return null; - } - - return { - width: rect.width, - height: rect.height - }; - }); - - if (!elementInfo) { - console.warn('Element is not visible or has invalid dimensions, skipping screenshot'); - return undefined; - } - - // Generate unique filename - const uuid = crypto.randomUUID(); - const filename = `${uuid}.png`; - const filePath = path.join(screenshotsDir, filename); - - // Take screenshot of the element with accurate colors and opacity - // This captures the element exactly as rendered in the browser with all CSS styles applied - await element.screenshot({ - path: filePath as `${string}.png`, - type: 'png', - omitBackground: true // Use transparent background for better quality - }); - - console.log(`Screenshot saved: ${filePath}`); - return filePath; - - } catch (error) { - console.error('Error taking element screenshot:', error); - return undefined; - } -} - diff --git a/servers/nextjs/types/element_attibutes.ts b/servers/nextjs/types/element_attibutes.ts index df113fc1..70df71f7 100644 --- a/servers/nextjs/types/element_attibutes.ts +++ b/servers/nextjs/types/element_attibutes.ts @@ -1,3 +1,5 @@ +import { ElementHandle } from "puppeteer"; + export interface ElementAttributes { tagName: string; id?: string; @@ -57,6 +59,8 @@ export interface ElementAttributes { shape?: 'rectangle' | 'circle'; connectorType?: string; textWrap?: boolean; + should_screenshot?: boolean; + element?: ElementHandle; } export interface SlideAttributesResult { diff --git a/servers/nextjs/utils/pptx_models_utils.ts b/servers/nextjs/utils/pptx_models_utils.ts index 35466f86..ec8d3631 100644 --- a/servers/nextjs/utils/pptx_models_utils.ts +++ b/servers/nextjs/utils/pptx_models_utils.ts @@ -1,4 +1,4 @@ -import { ElementAttributes } from "@/types/element_attibutes"; +import { ElementAttributes, SlideAttributesResult } from "@/types/element_attibutes"; import { PptxSlideModel, PptxTextBoxModel, @@ -63,15 +63,14 @@ function convertLineHeightToRelative(lineHeight?: number, fontSize?: number): nu } /** - * Converts ElementAttributes[][] to PptxSlideModel[] - * Each inner array represents elements on a slide + * Converts SlideAttributesResult[] to PptxSlideModel[] + * Each SlideAttributesResult represents elements on a slide */ export function convertElementAttributesToPptxSlides( - slidesAttributes: ElementAttributes[][], - backgroundColors?: (string | undefined)[] + slidesAttributes: SlideAttributesResult[] ): PptxSlideModel[] { - return slidesAttributes.map((slideElements, index) => { - const shapes = slideElements.map(element => { + return slidesAttributes.map((slideAttributes) => { + const shapes = slideAttributes.elements.map(element => { return convertElementToPptxShape(element); }).filter(Boolean); // Remove any null/undefined shapes @@ -80,9 +79,9 @@ export function convertElementAttributesToPptxSlides( }; // Add background color if available - if (backgroundColors && backgroundColors[index]) { + if (slideAttributes.backgroundColor) { slide.background = { - color: backgroundColors[index]!, + color: slideAttributes.backgroundColor, opacity: 1.0 }; }