diff --git a/servers/fastapi/api/main.py b/servers/fastapi/api/main.py index d6be4668..5c8aaec5 100644 --- a/servers/fastapi/api/main.py +++ b/servers/fastapi/api/main.py @@ -4,7 +4,7 @@ from fastapi.staticfiles import StaticFiles from api.lifespan import app_lifespan from api.middlewares import UserConfigEnvUpdateMiddleware from api.v1.ppt.router import API_V1_PPT_ROUTER -from utils.asset_directory_utils import get_images_directory +from utils.asset_directory_utils import get_exports_directory, get_images_directory app = FastAPI(lifespan=app_lifespan) @@ -20,6 +20,11 @@ app.mount( StaticFiles(directory=get_images_directory()), name="app_data/images", ) +app.mount( + "/app_data/exports", + StaticFiles(directory=get_exports_directory()), + name="app_data/exports", +) # Middlewares diff --git a/servers/fastapi/api/v1/ppt/endpoints/presentation.py b/servers/fastapi/api/v1/ppt/endpoints/presentation.py index cff0f7d5..5594a8c7 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/presentation.py +++ b/servers/fastapi/api/v1/ppt/endpoints/presentation.py @@ -21,7 +21,7 @@ from services.database import get_sql_session from services.documents_loader import DocumentsLoader from models.sql.presentation import PresentationModel from services.pptx_presentation_creator import PptxPresentationCreator -from utils.asset_directory_utils import get_export_directory +from utils.asset_directory_utils import get_exports_directory from utils.llm_calls.generate_document_summary import generate_document_summary from utils.llm_calls.generate_presentation_structure import ( generate_presentation_structure, @@ -281,10 +281,12 @@ def update_presentation( @PRESENTATION_ROUTER.post("/export/pptx", response_model=str) async def create_pptx(pptx_model: Annotated[PptxPresentationModel, Body()]): - pptx_creator = PptxPresentationCreator(pptx_model) + temp_dir = TEMP_FILE_SERVICE.create_temp_dir() + + pptx_creator = PptxPresentationCreator(pptx_model, temp_dir) await pptx_creator.create_ppt() - export_directory = get_export_directory() + export_directory = get_exports_directory() pptx_path = os.path.join( export_directory, f"{pptx_model.name or get_random_uuid()}.pptx" ) diff --git a/servers/fastapi/utils/asset_directory_utils.py b/servers/fastapi/utils/asset_directory_utils.py index 38125bd4..13a0673c 100644 --- a/servers/fastapi/utils/asset_directory_utils.py +++ b/servers/fastapi/utils/asset_directory_utils.py @@ -8,7 +8,7 @@ def get_images_directory(): return images_directory -def get_export_directory(): +def get_exports_directory(): export_directory = os.path.join(get_app_data_directory_env(), "exports") os.makedirs(export_directory, exist_ok=True) return export_directory diff --git a/servers/nextjs/app/(presentation-generator)/presentation/components/Header.tsx b/servers/nextjs/app/(presentation-generator)/presentation/components/Header.tsx index 640beb24..2518f450 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/components/Header.tsx +++ b/servers/nextjs/app/(presentation-generator)/presentation/components/Header.tsx @@ -47,6 +47,7 @@ import Modal from "./Modal"; import Announcement from "@/components/Announcement"; import { getFontLink, getStaticFileUrl } from "../../utils/others"; +import { PptxPresentationModel } from "@/types/pptx_models"; const Header = ({ @@ -121,44 +122,12 @@ const Header = ({ } }; - const getSlideMetadata = async () => { - try { - const metadata = await (await fetch('/api/slide-metadata', { - method: 'POST', - body: JSON.stringify({ - id: presentation_id, - }) - })).json() - - console.log("metadata", metadata); - return metadata; - } catch (error) { - setShowLoader(false); - console.error("Error fetching metadata:", error); - toast({ - title: "Error fetching slide metadata", - description: error instanceof Error ? error.message : "Failed to fetch metadata", - variant: "destructive", - }); - throw error; - } + const get_presentation_pptx_model = async (id: string): Promise => { + const response = await fetch(`/api/presentation_to_pptx_model?id=${id}`); + const pptx_model = await response.json(); + return pptx_model; }; - const metaData = async () => { - const body = { - presentation_id: presentation_id, - slides: presentationData?.slides, - }; - await PresentationGenerationApi.updatePresentationContent(body) - .then(() => { }) - .catch((error) => { - console.error(error); - }); - const apiBody = await getSlideMetadata(); - apiBody.presentation_id = presentation_id; - - return apiBody; - }; const handleExportPptx = async () => { if (isStreaming) return; @@ -166,12 +135,13 @@ const Header = ({ setOpen(false); setShowLoader(true); - const apiBody = await metaData(); - - const response = await PresentationGenerationApi.exportAsPPTX(apiBody); - if (response.path) { - const staticFileUrl = getStaticFileUrl(response.path); - window.open(staticFileUrl, '_self'); + const pptx_model = await get_presentation_pptx_model(presentation_id); + if (!pptx_model) { + throw new Error("Failed to get presentation PPTX model"); + } + const pptx_path = await PresentationGenerationApi.exportAsPPTX(pptx_model); + if (pptx_path) { + window.open(pptx_path, '_self'); } else { throw new Error("No path returned from export"); } diff --git a/servers/nextjs/app/(presentation-generator)/services/api/presentation-generation.ts b/servers/nextjs/app/(presentation-generator)/services/api/presentation-generation.ts index 6a53e489..801e1c31 100644 --- a/servers/nextjs/app/(presentation-generator)/services/api/presentation-generation.ts +++ b/servers/nextjs/app/(presentation-generator)/services/api/presentation-generation.ts @@ -243,7 +243,7 @@ export class PresentationGenerationApi { static async exportAsPPTX(presentationData: any) { try { const response = await fetch( - `/api/v1/ppt/presentation/export_as_pptx`, + `/api/v1/ppt/presentation/export/pptx`, { method: "POST", headers: getHeader(), 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 c8c9f72b..c7f718cb 100644 --- a/servers/nextjs/app/api/presentation_to_pptx_model/route.ts +++ b/servers/nextjs/app/api/presentation_to_pptx_model/route.ts @@ -1,24 +1,37 @@ import { ApiError } from "@/models/errors"; import { NextRequest, NextResponse } from "next/server"; -import puppeteer, { ElementHandle } from "puppeteer"; +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"; export async function GET(request: NextRequest) { - + let browser: Browser | null = null; try { const id = await getPresentationId(request); - const slides = await getSlides(id); + browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'] + }); + + const slides = await getSlides(browser, id); const slides_attributes = await getSlidesAttributes(slides); const slides_pptx_models = convertElementAttributesToPptxSlides(slides_attributes.elements, slides_attributes.backgroundColors); const presentation_pptx_model: PptxPresentationModel = { slides: slides_pptx_models, }; + + if (browser) { + await browser.close(); + } + return NextResponse.json(presentation_pptx_model); } catch (error: any) { console.error(error); + if (browser) { + await browser.close(); + } if (error instanceof ApiError) { return NextResponse.json(error, { status: 400 }); } @@ -39,7 +52,6 @@ async function getSlidesAttributes(slides: ElementHandle[]) { return await getAllChildElementsAttributes(slide); })); - // Extract elements and background colors from each slide result const elements = slideResults.map(result => result.elements); const backgroundColors = slideResults.map(result => result.backgroundColor); @@ -50,14 +62,14 @@ async function getSlidesAttributes(slides: ElementHandle[]) { } -async function getSlides(id: string) { - const slides_wrapper = await getSlidesWrapper(id); +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(id: string): Promise> { - const page = await getPresentationPage(id); +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"); @@ -66,12 +78,15 @@ async function getSlidesWrapper(id: string): Promise> { } -async function getPresentationPage(id: string) { - const browser = await puppeteer.launch({ - headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox'] - }); +async function getPresentationPage(browser: Browser, id: string) { const page = await browser.newPage(); + + page.on('console', (msg) => { + const type = msg.type(); + const text = msg.text(); + console.log(`[Puppeteer Console ${type.toUpperCase()}] ${text}`); + }); + await page.setViewport({ width: 1640, height: 720, deviceScaleFactor: 1 }); await page.goto(`http://localhost/presentation?id=${id}`, { waitUntil: "networkidle0", @@ -82,7 +97,6 @@ async function getPresentationPage(id: string) { async function getAllChildElementsAttributes(element: ElementHandle): Promise { - // Get the root element's bounding rect for relative positioning const rootRect = await element.evaluate((el) => { const rect = el.getBoundingClientRect(); return { @@ -93,14 +107,11 @@ async function getAllChildElementsAttributes(element: ElementHandle): P }; }); - // Get all child elements as ElementHandles const childElementHandles = await element.$$(':scope *'); - // Get attributes and depth for each child element const attributesPromises = childElementHandles.map(async (childElementHandle) => { const attributes = await getElementAttributes(childElementHandle); - // Calculate the depth of the element in the DOM tree const depth = await childElementHandle.evaluate((el) => { let depth = 0; let current = el; @@ -111,7 +122,6 @@ async function getAllChildElementsAttributes(element: ElementHandle): P return depth; }); - // Convert positions to relative positions if (attributes.position && attributes.position.left !== undefined && attributes.position.top !== undefined) { attributes.position = { left: attributes.position.left - rootRect.left, @@ -126,7 +136,6 @@ async function getAllChildElementsAttributes(element: ElementHandle): P const allResults = await Promise.all(attributesPromises); - // Extract background color from elements whose position is the same as root element let backgroundColor: string | undefined; const elementsWithRootPosition = allResults.filter(({ attributes }) => { return attributes.position && @@ -136,7 +145,6 @@ async function getAllChildElementsAttributes(element: ElementHandle): P attributes.position.height === rootRect.height; }); - // Get the background color from the first element with root position that has a background for (const { attributes } of elementsWithRootPosition) { if (attributes.background && attributes.background.color) { backgroundColor = attributes.background.color; @@ -144,40 +152,41 @@ async function getAllChildElementsAttributes(element: ElementHandle): P } } - // Filter out elements with no meaningful styling and elements with same position as root const filteredResults = allResults.filter(({ attributes }) => { - // Check if element has any meaningful styling or content - const hasBackground = attributes.background && attributes.background.color; + const hasOwnBackground = attributes.background && attributes.background.color && !attributes.background.isInherited; + const hasInheritedBackground = attributes.background && attributes.background.color && attributes.background.isInherited; 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; - // Check if element position is the same as root (exclude these elements) const isRootPosition = attributes.position && attributes.position.left === 0 && attributes.position.top === 0 && attributes.position.width === rootRect.width && attributes.position.height === rootRect.height; - // Return true if element has at least one of these properties AND is not at root position - return (hasBackground || hasBorder || hasShadow || hasText) && !isRootPosition; + // Include elements that have meaningful visual properties + // Elements with own background colors, borders, shadows, text, or images should be included + // Elements with only inherited background colors should only be included if they have other properties + const hasOtherProperties = hasBorder || hasShadow || hasText || hasImage; + const hasVisualProperties = hasOwnBackground || hasOtherProperties || (hasInheritedBackground && hasOtherProperties); + + return hasVisualProperties && !isRootPosition; }); - // Sort elements by z-index first, then by depth if z-index is not provided const sortedElements = filteredResults .sort((a, b) => { const zIndexA = a.attributes.zIndex || 0; const zIndexB = b.attributes.zIndex || 0; - // If both elements have the same z-index (including 0), sort by depth if (zIndexA === zIndexB) { - return b.depth - a.depth; // Higher depth first (children before parents) + return b.depth - a.depth; } - // Otherwise sort by z-index (higher z-index first, as elements below come first) return zIndexB - zIndexA; }) - .map(({ attributes }) => attributes); // Extract just the attributes + .map(({ attributes }) => attributes); return { elements: sortedElements, @@ -188,27 +197,68 @@ async function getAllChildElementsAttributes(element: ElementHandle): P async function getElementAttributes(element: ElementHandle): Promise { const attributes = await element.evaluate((el) => { - // Helper function to convert color to hex - function colorToHex(color: string): string | undefined { + function colorToHex(color: string): { hex: string | undefined; opacity: number | undefined } { if (!color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)') { - return undefined; + return { hex: undefined, opacity: undefined }; + } + + if (color.startsWith('rgba(') || color.startsWith('hsla(')) { + const match = color.match(/rgba?\(([^)]+)\)|hsla?\(([^)]+)\)/); + if (match) { + const values = match[1] || match[2]; + const parts = values.split(',').map(part => part.trim()); + + if (parts.length >= 4) { + const opacity = parseFloat(parts[3]); + const rgbColor = color.replace(/rgba?\(|hsla?\(|\)/g, '').split(',').slice(0, 3).join(','); + const rgbString = color.startsWith('rgba') ? `rgb(${rgbColor})` : `hsl(${rgbColor})`; + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.fillStyle = rgbString; + const hexColor = ctx.fillStyle; + const hex = hexColor.startsWith('#') ? hexColor.substring(1) : hexColor; + const result = { hex, opacity: isNaN(opacity) ? undefined : opacity }; + + return result; + } + } + } + } + + if (color.startsWith('rgb(') || color.startsWith('hsl(')) { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.fillStyle = color; + const hexColor = ctx.fillStyle; + const hex = hexColor.startsWith('#') ? hexColor.substring(1) : hexColor; + return { hex, opacity: undefined }; + } + } + + if (color.startsWith('#')) { + const hex = color.substring(1); + return { hex, opacity: undefined }; } - // Create a temporary canvas to convert colors to hex const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); - if (!ctx) return color; + if (!ctx) return { hex: color, opacity: undefined }; ctx.fillStyle = color; - return ctx.fillStyle; + const hexColor = ctx.fillStyle; + const hex = hexColor.startsWith('#') ? hexColor.substring(1) : hexColor; + const result = { hex, opacity: undefined }; + + return result; } - // Helper function to check if element has only text nodes as direct children function hasOnlyTextNodes(el: Element): boolean { const children = el.childNodes; for (let i = 0; i < children.length; i++) { const child = children[i]; - // If any child is an element node (not a text node), return false if (child.nodeType === Node.ELEMENT_NODE) { return false; } @@ -216,171 +266,454 @@ async function getElementAttributes(element: ElementHandle): Promise= 4) { - const offsetX = parseFloat(shadowParts[0]); - const offsetY = parseFloat(shadowParts[1]); - const blurRadius = parseFloat(shadowParts[2]); - shadow = { - offset: (!isNaN(offsetX) && !isNaN(offsetY)) ? [offsetX, offsetY] as [number, number] : undefined, - color: colorToHex(shadowParts[3]), - opacity: 1, - radius: !isNaN(blurRadius) ? blurRadius : undefined, - angle: !isNaN(offsetX) && !isNaN(offsetY) ? Math.atan2(offsetY, offsetX) * (180 / Math.PI) : undefined, - }; + function getInheritedBackgroundColor(el: Element): { color: string | undefined; opacity: number | undefined } { + let current = el.parentElement; + while (current) { + const computedStyles = window.getComputedStyle(current); + const backgroundColorResult = colorToHex(computedStyles.backgroundColor); + // Only return inherited background if it's not transparent and has a meaningful color + // (not black or white, which are likely defaults) + if (backgroundColorResult.hex && backgroundColorResult.opacity !== 0 && + backgroundColorResult.hex !== '000000' && backgroundColorResult.hex !== 'ffffff' && + backgroundColorResult.hex !== 'transparent') { + return { + color: backgroundColorResult.hex, + opacity: backgroundColorResult.opacity, + }; + } + current = current.parentElement; } + return { color: undefined, opacity: undefined }; } - // Parse font - const fontSize = parseFloat(computedStyles.fontSize); - const fontWeight = parseInt(computedStyles.fontWeight); - const fontColor = colorToHex(computedStyles.color); - const fontFamily = computedStyles.fontFamily; - const fontStyle = computedStyles.fontStyle; + function parseBackground(computedStyles: CSSStyleDeclaration, el?: Element, hasShadow?: boolean) { + const backgroundColorResult = colorToHex(computedStyles.backgroundColor); - // Extract only the first font from font-family (e.g., "Hack, sans-serif" -> "Hack") - let fontName = undefined; - if (fontFamily !== 'initial') { - const firstFont = fontFamily.split(',')[0].trim().replace(/['"]/g, ''); - fontName = firstFont; - } - - const font = { - name: fontName, - size: isNaN(fontSize) ? undefined : fontSize, - weight: isNaN(fontWeight) ? undefined : fontWeight, - color: fontColor, - italic: fontStyle === 'italic', - }; - - // Parse margin - const marginTop = parseFloat(computedStyles.marginTop); - const marginBottom = parseFloat(computedStyles.marginBottom); - const marginLeft = parseFloat(computedStyles.marginLeft); - const marginRight = parseFloat(computedStyles.marginRight); - const marginObj = { - top: isNaN(marginTop) ? undefined : marginTop, - bottom: isNaN(marginBottom) ? undefined : marginBottom, - left: isNaN(marginLeft) ? undefined : marginLeft, - right: isNaN(marginRight) ? undefined : marginRight, - }; - - // Set margin as undefined if all fields are 0 - const margin = (marginObj.top === 0 && marginObj.bottom === 0 && marginObj.left === 0 && marginObj.right === 0) - ? undefined - : marginObj; - - // Parse padding - const paddingTop = parseFloat(computedStyles.paddingTop); - const paddingBottom = parseFloat(computedStyles.paddingBottom); - const paddingLeft = parseFloat(computedStyles.paddingLeft); - const paddingRight = parseFloat(computedStyles.paddingRight); - const paddingObj = { - top: isNaN(paddingTop) ? undefined : paddingTop, - bottom: isNaN(paddingBottom) ? undefined : paddingBottom, - left: isNaN(paddingLeft) ? undefined : paddingLeft, - right: isNaN(paddingRight) ? undefined : paddingRight, - }; - - // Set padding as undefined if all fields are 0 - const padding = (paddingObj.top === 0 && paddingObj.bottom === 0 && paddingObj.left === 0 && paddingObj.right === 0) - ? undefined - : paddingObj; - - // Only include innerText if the element has only text nodes as direct children - const innerText = hasOnlyTextNodes(el) ? (el.textContent || undefined) : undefined; - - // Parse z-index - const zIndex = parseInt(computedStyles.zIndex); - const zIndexValue = isNaN(zIndex) ? 0 : zIndex; - - // Parse additional attributes - const textAlign = computedStyles.textAlign as 'left' | 'center' | 'right' | 'justify'; - const borderRadius = computedStyles.borderRadius; - const objectFit = computedStyles.objectFit as 'contain' | 'cover' | 'fill' | undefined; - const imageSrc = (el as HTMLImageElement).src; - - // Parse border radius - let borderRadiusValue: number | number[] | undefined; - if (borderRadius && borderRadius !== '0px') { - const radiusParts = borderRadius.split(' ').map(part => parseFloat(part)); - if (radiusParts.length === 1) { - borderRadiusValue = radiusParts[0]; - } else if (radiusParts.length === 4) { - borderRadiusValue = radiusParts; + // Only use inherited background if the element has no background color at all + // and has a shadow (indicating it might need a background for the shadow to be visible) + if (!backgroundColorResult.hex && hasShadow && el) { + const inheritedBackground = getInheritedBackgroundColor(el); + if (inheritedBackground.color) { + return { + ...inheritedBackground, + isInherited: true + }; + } } + + return { + color: backgroundColorResult.hex, + opacity: backgroundColorResult.opacity, + isInherited: false + }; } - // Determine shape for images - let shape: 'rectangle' | 'circle' | undefined; - if (el.tagName.toLowerCase() === 'img') { - shape = borderRadiusValue === 50 ? 'circle' : 'rectangle'; + function parseBorder(computedStyles: CSSStyleDeclaration) { + const borderColorResult = colorToHex(computedStyles.borderColor); + const borderWidth = parseFloat(computedStyles.borderWidth); + return borderWidth === 0 ? undefined : { + color: borderColorResult.hex, + width: isNaN(borderWidth) ? undefined : borderWidth, + }; } - // Check for text wrap - const textWrap = computedStyles.whiteSpace !== 'nowrap'; + function parseShadow(computedStyles: CSSStyleDeclaration) { + const boxShadow = computedStyles.boxShadow; + if (boxShadow !== 'none') { + console.log(`Parsing shadow: ${boxShadow}`); + } + let shadow: { + offset?: [number, number]; + color?: string; + opacity?: number; + radius?: number; + angle?: number; + spread?: number; + inset?: boolean; + } = {}; - return { - tagName: el.tagName.toLowerCase(), - id: el.id || undefined, - className: el.className || undefined, - innerText, - background, - border, - shadow, - font, - position, - margin, - padding, - zIndex: zIndexValue, - textAlign: textAlign !== 'left' ? textAlign : undefined, - borderRadius: borderRadiusValue, - imageSrc: imageSrc || undefined, - objectFit, - clip: false, // Default value - overlay: undefined, - shape, - connectorType: undefined, - textWrap, - }; + if (boxShadow && boxShadow !== 'none') { + // Handle multiple shadows (comma-separated) - find the first meaningful one + // Need to split on commas but not inside function calls like rgba() + const shadows: string[] = []; + let currentShadow = ''; + let parenCount = 0; + + for (let i = 0; i < boxShadow.length; i++) { + const char = boxShadow[i]; + if (char === '(') { + parenCount++; + } else if (char === ')') { + parenCount--; + } else if (char === ',' && parenCount === 0) { + // This comma is outside of any function call, so it separates shadows + shadows.push(currentShadow.trim()); + currentShadow = ''; + continue; + } + currentShadow += char; + } + + // Add the last shadow + if (currentShadow.trim()) { + shadows.push(currentShadow.trim()); + } + + console.log(`Split shadows: ${JSON.stringify(shadows)}`); + + let selectedShadow = ''; + let bestShadowScore = -1; + + for (let i = 0; i < shadows.length; i++) { + const shadowStr = shadows[i]; + console.log(`Analyzing shadow ${i}: "${shadowStr}"`); + + // Parse the shadow to check if it has meaningful values + const shadowParts = shadowStr.split(' '); + const numericParts: number[] = []; + const colorParts: string[] = []; + let isInset = false; + let currentColor = ''; + let inColorFunction = false; + + // Parse each part + for (let j = 0; j < shadowParts.length; j++) { + const part = shadowParts[j]; + const trimmedPart = part.trim(); + if (trimmedPart === '') continue; + + if (trimmedPart.toLowerCase() === 'inset') { + isInset = true; + continue; + } + + // Check if this part starts a color function (rgba, rgb, hsl, hsla) + if (trimmedPart.match(/^(rgba?|hsla?)\s*\(/i)) { + inColorFunction = true; + currentColor = trimmedPart; + continue; + } + + // If we're inside a color function, keep building it + if (inColorFunction) { + currentColor += ' ' + trimmedPart; + + // Check if we've reached the end of the color function + const openParens = (currentColor.match(/\(/g) || []).length; + const closeParens = (currentColor.match(/\)/g) || []).length; + + if (openParens <= closeParens) { + // Color function is complete + colorParts.push(currentColor); + currentColor = ''; + inColorFunction = false; + } + continue; + } + + const numericValue = parseFloat(trimmedPart); + if (!isNaN(numericValue)) { + numericParts.push(numericValue); + } else { + colorParts.push(trimmedPart); + } + } + + console.log(`Shadow ${i} - numericParts: ${JSON.stringify(numericParts)}, colorParts: ${JSON.stringify(colorParts)}`); + + // Check if the color is not completely transparent using colorToHex + let hasVisibleColor = false; + if (colorParts.length > 0) { + const shadowColor = colorParts.join(' '); + const colorResult = colorToHex(shadowColor); + hasVisibleColor = !!(colorResult.hex && colorResult.hex !== '000000' && colorResult.opacity !== 0); + console.log(`Shadow ${i} color analysis - color: "${shadowColor}", result: ${JSON.stringify(colorResult)}, hasVisibleColor: ${hasVisibleColor}`); + } + + // Check if we have any non-zero numeric values (offset, blur, or spread) + const hasNonZeroValues = numericParts.some(value => value !== 0); + + console.log(`Shadow ${i} - hasNonZeroValues: ${hasNonZeroValues}, hasVisibleColor: ${hasVisibleColor}`); + + // 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 + } + + // Select this shadow if it has a better score + if ((hasNonZeroValues || hasVisibleColor) && shadowScore > bestShadowScore) { + selectedShadow = shadowStr; + bestShadowScore = shadowScore; + console.log(`Selected shadow ${i} with score ${shadowScore}: "${selectedShadow}"`); + } + } + + // If no meaningful shadow found, use the first one + if (!selectedShadow && shadows.length > 0) { + selectedShadow = shadows[0]; + console.log(`No meaningful shadow found, using first: "${selectedShadow}"`); + } + + if (selectedShadow) { + console.log(`Parsing selected shadow: "${selectedShadow}"`); + + // Parse the selected shadow + const shadowParts = selectedShadow.split(' '); + const numericParts: number[] = []; + const colorParts: string[] = []; + let isInset = false; + let currentColor = ''; + let inColorFunction = false; + + // Parse each part + for (let i = 0; i < shadowParts.length; i++) { + const part = shadowParts[i]; + const trimmedPart = part.trim(); + if (trimmedPart === '') continue; + + if (trimmedPart.toLowerCase() === 'inset') { + isInset = true; + continue; + } + + // Check if this part starts a color function (rgba, rgb, hsl, hsla) + if (trimmedPart.match(/^(rgba?|hsla?)\s*\(/i)) { + inColorFunction = true; + currentColor = trimmedPart; + continue; + } + + // If we're inside a color function, keep building it + if (inColorFunction) { + currentColor += ' ' + trimmedPart; + + // Check if we've reached the end of the color function + const openParens = (currentColor.match(/\(/g) || []).length; + const closeParens = (currentColor.match(/\)/g) || []).length; + + if (openParens <= closeParens) { + // Color function is complete + colorParts.push(currentColor); + currentColor = ''; + inColorFunction = false; + } + continue; + } + + const numericValue = parseFloat(trimmedPart); + if (!isNaN(numericValue)) { + numericParts.push(numericValue); + } else { + colorParts.push(trimmedPart); + } + } + + console.log(`Selected shadow parsing - numericParts: ${JSON.stringify(numericParts)}, colorParts: ${JSON.stringify(colorParts)}`); + + // Handle different shadow formats + if (numericParts.length >= 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(' '); + } + + const shadowColorResult = colorToHex(shadowColor); + console.log(`Shadow color result: ${JSON.stringify(shadowColorResult)}`); + + // Create shadow object if we have any meaningful values or visible color + const hasValidValues = offsetX !== 0 || offsetY !== 0 || blurRadius > 0 || spreadRadius !== 0 || + (shadowColorResult.hex && shadowColorResult.hex !== '000000' && shadowColorResult.opacity !== 0); + + console.log(`Has valid values: ${hasValidValues} (offsetX: ${offsetX}, offsetY: ${offsetY}, blurRadius: ${blurRadius}, spreadRadius: ${spreadRadius}, color: ${shadowColorResult.hex}, opacity: ${shadowColorResult.opacity})`); + + if (hasValidValues) { + shadow = { + offset: [offsetX, offsetY], + color: shadowColorResult.hex || '000000', + opacity: shadowColorResult.opacity, + radius: blurRadius, + spread: spreadRadius, + inset: isInset, + angle: Math.atan2(offsetY, offsetX) * (180 / Math.PI), + }; + console.log(`Created shadow object: ${JSON.stringify(shadow)}`); + } + } + } + } + + if (boxShadow !== 'none') { + console.log(`Final parsed shadow: ${JSON.stringify(shadow)}`); + } + + return shadow; + } + + function parseFont(computedStyles: CSSStyleDeclaration) { + const fontSize = parseFloat(computedStyles.fontSize); + const fontWeight = parseInt(computedStyles.fontWeight); + const fontColorResult = colorToHex(computedStyles.color); + const fontFamily = computedStyles.fontFamily; + const fontStyle = computedStyles.fontStyle; + + let fontName = undefined; + if (fontFamily !== 'initial') { + const firstFont = fontFamily.split(',')[0].trim().replace(/['"]/g, ''); + fontName = firstFont; + } + + return { + name: fontName, + size: isNaN(fontSize) ? undefined : fontSize, + weight: isNaN(fontWeight) ? undefined : fontWeight, + color: fontColorResult.hex, + italic: fontStyle === 'italic', + }; + } + + function parseMargin(computedStyles: CSSStyleDeclaration) { + const marginTop = parseFloat(computedStyles.marginTop); + const marginBottom = parseFloat(computedStyles.marginBottom); + const marginLeft = parseFloat(computedStyles.marginLeft); + const marginRight = parseFloat(computedStyles.marginRight); + const marginObj = { + top: isNaN(marginTop) ? undefined : marginTop, + bottom: isNaN(marginBottom) ? undefined : marginBottom, + left: isNaN(marginLeft) ? undefined : marginLeft, + right: isNaN(marginRight) ? undefined : marginRight, + }; + + return (marginObj.top === 0 && marginObj.bottom === 0 && marginObj.left === 0 && marginObj.right === 0) + ? undefined + : marginObj; + } + + function parsePadding(computedStyles: CSSStyleDeclaration) { + const paddingTop = parseFloat(computedStyles.paddingTop); + const paddingBottom = parseFloat(computedStyles.paddingBottom); + const paddingLeft = parseFloat(computedStyles.paddingLeft); + const paddingRight = parseFloat(computedStyles.paddingRight); + const paddingObj = { + top: isNaN(paddingTop) ? undefined : paddingTop, + bottom: isNaN(paddingBottom) ? undefined : paddingBottom, + left: isNaN(paddingLeft) ? undefined : paddingLeft, + right: isNaN(paddingRight) ? undefined : paddingRight, + }; + + return (paddingObj.top === 0 && paddingObj.bottom === 0 && paddingObj.left === 0 && paddingObj.right === 0) + ? undefined + : paddingObj; + } + + function parseBorderRadius(computedStyles: CSSStyleDeclaration) { + const borderRadius = computedStyles.borderRadius; + let borderRadiusValue; + + if (borderRadius && borderRadius !== '0px') { + const radiusParts = borderRadius.split(' ').map(part => parseFloat(part)); + if (radiusParts.length === 1) { + borderRadiusValue = [radiusParts[0], radiusParts[0], radiusParts[0], radiusParts[0]]; + } else if (radiusParts.length === 2) { + borderRadiusValue = [radiusParts[0], radiusParts[1], radiusParts[0], radiusParts[1]]; + } else if (radiusParts.length === 3) { + borderRadiusValue = [radiusParts[0], radiusParts[1], radiusParts[2], radiusParts[1]]; + } else if (radiusParts.length === 4) { + borderRadiusValue = radiusParts; + } + } + + return borderRadiusValue; + } + + function parseShape(el: Element, borderRadiusValue: number[] | undefined) { + if (el.tagName.toLowerCase() === 'img') { + return borderRadiusValue && borderRadiusValue.length === 4 && + borderRadiusValue.every((radius: number) => radius === 50) ? 'circle' : 'rectangle'; + } + return undefined; + } + + function parseElementAttributes(el: Element) { + const computedStyles = window.getComputedStyle(el); + + const position = parsePosition(el); + + const shadow = parseShadow(computedStyles); + + const background = parseBackground(computedStyles, el, !!shadow); + + const border = parseBorder(computedStyles); + + const font = parseFont(computedStyles); + + const margin = parseMargin(computedStyles); + + const padding = parsePadding(computedStyles); + + const innerText = hasOnlyTextNodes(el) ? (el.textContent || undefined) : undefined; + + const zIndex = parseInt(computedStyles.zIndex); + const zIndexValue = isNaN(zIndex) ? 0 : zIndex; + + const textAlign = computedStyles.textAlign as 'left' | 'center' | 'right' | 'justify'; + const objectFit = computedStyles.objectFit as 'contain' | 'cover' | 'fill' | undefined; + const imageSrc = (el as HTMLImageElement).src; + + const borderRadiusValue = parseBorderRadius(computedStyles); + + const shape = parseShape(el, borderRadiusValue) as 'rectangle' | 'circle' | undefined; + + const textWrap = computedStyles.whiteSpace !== 'nowrap'; + + return { + tagName: el.tagName.toLowerCase(), + id: el.id || undefined, + className: (el.className && typeof el.className === 'string') ? el.className : (el.className ? el.className.toString() : undefined), + innerText, + background, + border, + shadow, + font, + position, + margin, + padding, + zIndex: zIndexValue, + textAlign: textAlign !== 'left' ? textAlign : undefined, + borderRadius: borderRadiusValue, + imageSrc: imageSrc || undefined, + objectFit, + clip: false, + overlay: undefined, + shape, + connectorType: undefined, + textWrap, + }; + } + + return parseElementAttributes(el); }); return attributes; } diff --git a/servers/nextjs/app/api/slide-metadata/route.ts b/servers/nextjs/app/api/slide-metadata/route.ts deleted file mode 100644 index 5902726b..00000000 --- a/servers/nextjs/app/api/slide-metadata/route.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import puppeteer from "puppeteer"; -import fs from 'fs'; -import path from 'path'; -import os from 'os'; - -interface Position { - left: number; - top: number; - width: number; - height: number; -} - -interface FontStyles { - name: string; - size: number; - bold: boolean; - weight: number; - color: string; -} - -interface TextElement { - position: Position; - paragraphs: { - alignment: number; - text: string; - font: FontStyles; - }[]; -} - -interface PictureElement { - position: Position; - picture: { - is_network: boolean; - path: string; - }; - shape: string | null; - object_fit: { - fit: string | null; - focus: number[]; - }; - overlay: string | null; - border_radius: number[]; -} - -interface BoxElement { - position: Position; - type: number; - fill: { - color: string; - }; - border_radius: number; - stroke: { - color: string; - thickness: number; - }; - shadow: { - radius: number; - color: string; - offset: number; - opacity: number; - angle: number; - }; -} - -interface LineElement { - position: Position; - lineType: number; - thickness: string; - color: string; -} - -interface GraphElement { - position: Position; - picture: { - is_network: boolean; - path: string; - }; - border_radius: number[]; -} - -type SlideElement = TextElement | PictureElement | BoxElement | LineElement | GraphElement; - -interface SlideMetadata { - slideIndex: number; - backgroundColor: string; - elements: SlideElement[]; -} - - -export async function POST(request: NextRequest) { - let browser; - try { - const body = await request.json(); - const { id } = body; - if (!id) { - return NextResponse.json({ error: "Missing Presentation ID" }, { status: 400 }); - } - browser = await puppeteer.launch({ - headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox'] - }); - - const page = await browser.newPage(); - await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 }); - - try { - await page.goto(`http://localhost/pdf-maker?id=${id}`, { - waitUntil: "networkidle0", - timeout: 60000, - }); - } catch (error) { - await browser.close(); - return NextResponse.json({ error: "Failed to navigate to provided URL" }, { status: 500 }); - } - - try { - await page.waitForSelector('[data-element-type="slide-container"]', { - timeout: 60000, - }); - - } catch (error) { - await browser.close(); - return NextResponse.json({ error: "Slide container not found" }, { status: 500 }); - } - - const metadata = await page.evaluate(async () => { - function rgbToHex(color: string) { - if (!color || color === "transparent" || color === "none") return "000000"; - if (color.startsWith("#")) return color.replace("#", ""); - const matches = color.match(/\d+/g); - if (!matches) return "000000"; - const r = parseInt(matches[0]); - const g = parseInt(matches[1]); - const b = parseInt(matches[2]); - return [r, g, b].map((x) => x.toString(16).padStart(2, "0")).join(""); - } - - async function collectSlideMetadata(): Promise { - const slidesMetadata: SlideMetadata[] = []; - const slideContainers = Array.from( - document.querySelectorAll('[data-element-type="slide-container"]') - ); - - for (const container of slideContainers) { - const containerEl = container as HTMLElement; - containerEl.style.width = "1280px"; - containerEl.style.height = "720px"; - containerEl.style.transform = "none"; - - const containerRect = containerEl.getBoundingClientRect(); - const slideIndex = parseInt( - containerEl.getAttribute("data-slide-index") || "0" - ); - const containerComputedStyle = window.getComputedStyle(containerEl); - - const slideMetadata: SlideMetadata = { - slideIndex, - backgroundColor: rgbToHex(containerComputedStyle.backgroundColor), - elements: [], - }; - const slideType = containerEl.getAttribute("data-slide-type"); - - const elements = Array.from( - containerEl.querySelectorAll( - '[data-slide-element]:not([data-element-type="slide-container"])' - ) - ); - - for (const element of elements) { - const el = element as HTMLElement; - const isIcon = el.getAttribute("data-is-icon"); - const isAlign = el.getAttribute("data-is-align"); - - const elementRect = el.getBoundingClientRect(); - const computedStyle = window.getComputedStyle(el); - - const position: Position = { - left: Math.round(elementRect.left - containerRect.left), - top: Math.round(elementRect.top - containerRect.top), - width: Math.round(elementRect.width), - height: Math.round(elementRect.height), - }; - - const elementType = el.getAttribute("data-element-type"); - if (!elementType) continue; - - const fontStyles: FontStyles = { - name: computedStyle.fontFamily.split('_')[2] || 'Inter', - size: parseInt(computedStyle.fontSize), - bold: parseInt(computedStyle.fontWeight) >= 500 ? true : false, - weight: parseInt(computedStyle.fontWeight), - color: rgbToHex(computedStyle.color), - }; - - switch (elementType) { - case "text": - const textContent = el.getAttribute("data-text-content"); - const textElement: TextElement = { - position, - paragraphs: [ - { - alignment: isAlign === 'true' ? 2 : 1, - text: textContent || el.textContent || "", - font: fontStyles, - }, - ], - }; - slideMetadata.elements.push(textElement); - break; - - case "picture": - const imgEl = el.tagName.toLowerCase() === "img" ? el as HTMLImageElement : el.querySelector("img") as HTMLImageElement; - if (imgEl) { - const focialPointx = parseFloat(imgEl.getAttribute('data-focial-point-x') || '0'); - const focialPointy = parseFloat(imgEl.getAttribute('data-focial-point-y') || '0'); - const image_type = imgEl.getAttribute('data-image-type'); - const objectFit = imgEl.getAttribute('data-object-fit'); - - const pictureElement: PictureElement = { - position, - picture: { - is_network: imgEl.src.startsWith("http"), - path: imgEl.src || imgEl.getAttribute("data-image-path") || "", - }, - shape: image_type, - object_fit: { - fit: objectFit, - focus: [focialPointx, focialPointy], - }, - overlay: isIcon ? "ffffff" : null, - border_radius: slideType === "4" - ? [parseInt(computedStyle.borderRadius), parseInt(computedStyle.borderRadius), 0, 0] - : [parseInt(computedStyle.borderRadius), parseInt(computedStyle.borderRadius), parseInt(computedStyle.borderRadius), parseInt(computedStyle.borderRadius)], - }; - slideMetadata.elements.push(pictureElement); - } - break; - - case "slide-box": - case "filledbox": - const boxShadow = computedStyle.boxShadow; - let shadowRadius = 0; - let shadowColor = "000000"; - let shadowOffsetX = 0; - let shadowOffsetY = 0; - let shadowOpacity = 0; - - if (boxShadow && boxShadow !== "none") { - const boxShadowRegex = /rgba?\((\d+),\s*(\d+),\s*(\d+),?\s*([\d.]+)?\)?\s+(-?\d+)px\s+(-?\d+)px\s+(-?\d+)px/; - const match = boxShadow.match(boxShadowRegex); - - if (match) { - const r = match[1]; - const g = match[2]; - const b = match[3]; - const rgbStr = `rgb(${r}, ${g}, ${b})`; - shadowColor = rgbToHex(rgbStr); - shadowOpacity = match[4] ? parseFloat(match[4]) : 1; - shadowOffsetX = parseInt(match[5]); - shadowOffsetY = parseInt(match[6]); - shadowRadius = parseInt(match[7]); - } - } - - const boxElement: BoxElement = { - position, - type: computedStyle.borderRadius === "9999px" || computedStyle.borderRadius === "50%" ? 9 : 5, - fill: { - color: rgbToHex(computedStyle.backgroundColor), - }, - border_radius: parseInt(computedStyle.borderRadius) || 0, - stroke: { - color: rgbToHex(computedStyle.borderColor), - thickness: parseInt(computedStyle.borderWidth) || 0, - }, - shadow: { - radius: shadowRadius, - color: shadowColor, - offset: Math.sqrt(shadowOffsetX * shadowOffsetX + shadowOffsetY * shadowOffsetY), - opacity: shadowOpacity, - angle: Math.round((Math.atan2(shadowOffsetY, shadowOffsetX) * 180) / Math.PI), - }, - }; - slideMetadata.elements.push(boxElement); - break; - - case "line": - const lineElement: LineElement = { - position, - lineType: 1, - thickness: computedStyle.borderWidth || computedStyle.height, - color: rgbToHex(computedStyle.borderColor || computedStyle.backgroundColor), - }; - slideMetadata.elements.push(lineElement); - break; - - case "graph": - const graphId = el.getAttribute("data-element-id"); - const graphElement: GraphElement = { - position, - picture: { - is_network: true, - path: `__GRAPH_PLACEHOLDER__${graphId}`, - }, - border_radius: [0, 0, 0, 0], - }; - slideMetadata.elements.push(graphElement); - break; - } - } - - slidesMetadata.push(slideMetadata); - } - - return slidesMetadata; - } - - return await collectSlideMetadata(); - }); - - const graphElements = await page.$$('[data-element-type="graph"]'); - - for (const graphElement of graphElements) { - const graphId = await graphElement.evaluate((el: Element) => - el.getAttribute("data-element-id") - ); - - const screenshot = await graphElement.screenshot({ - type: "jpeg", - encoding: "base64", - quality: 100, - omitBackground: true, - }); - - try { - const tempDir = process.env.TEMP_DIRECTORY || os.tmpdir(); - - // Generate a unique filename - const filename = `chart-${graphId}-${Date.now()}.jpg`; - const filePath = path.join(tempDir, filename); - - // Save the file - fs.writeFileSync(filePath, Buffer.from(screenshot, 'base64')); - - metadata.forEach((slide) => { - slide.elements.forEach((element) => { - if ('picture' in element && element.picture.path === `__GRAPH_PLACEHOLDER__${graphId}`) { - element.picture.path = filePath; - } - }); - }); - } catch (error) { - console.error('Error saving screenshot:', error); - continue; - } - } - await browser.close(); - - - const slides = metadata.map((slide: any, index: any) => { - return { - shapes: slide.elements, - }; - }); - - const apiBody = { - pptx_model: { - background_color: metadata[0].backgroundColor, - slides: slides, - }, - }; - - return NextResponse.json(apiBody); - } catch (error) { - console.error("Error during page preparation:", error); - if (browser) await browser.close(); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); - } finally { - if (browser) await browser.close(); - } -} \ No newline at end of file diff --git a/servers/nextjs/test-shadow-formats.html b/servers/nextjs/test-shadow-formats.html new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/servers/nextjs/test-shadow-formats.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/servers/nextjs/types/element_attibutes.ts b/servers/nextjs/types/element_attibutes.ts index 68adab9b..14a9eaca 100644 --- a/servers/nextjs/types/element_attibutes.ts +++ b/servers/nextjs/types/element_attibutes.ts @@ -6,6 +6,7 @@ export interface ElementAttributes { background?: { color?: string; opacity?: number; + isInherited?: boolean; }; border?: { color?: string; @@ -17,6 +18,8 @@ export interface ElementAttributes { opacity?: number; radius?: number; angle?: number; + spread?: number; + inset?: boolean; }, font?: { name?: string; @@ -45,7 +48,7 @@ export interface ElementAttributes { }; zIndex?: number; textAlign?: 'left' | 'center' | 'right' | 'justify'; - borderRadius?: number | number[]; + borderRadius?: number[]; imageSrc?: string; objectFit?: 'contain' | 'cover' | 'fill'; clip?: boolean; diff --git a/servers/nextjs/types/pptx_models.ts b/servers/nextjs/types/pptx_models.ts index e3224998..1f47d86f 100644 --- a/servers/nextjs/types/pptx_models.ts +++ b/servers/nextjs/types/pptx_models.ts @@ -9,26 +9,229 @@ export enum PptxObjectFitEnum { FILL = "fill" } +export enum PptxAlignment { + CENTER = 2, + DISTRIBUTE = 5, + JUSTIFY = 4, + JUSTIFY_LOW = 7, + LEFT = 1, + RIGHT = 3, + THAI_DISTRIBUTE = 6, + MIXED = -2 +} + +export enum PptxShapeType { + ACTION_BUTTON_BACK_OR_PREVIOUS = 129, + ACTION_BUTTON_BEGINNING = 131, + ACTION_BUTTON_CUSTOM = 125, + ACTION_BUTTON_DOCUMENT = 134, + ACTION_BUTTON_END = 132, + ACTION_BUTTON_FORWARD_OR_NEXT = 130, + ACTION_BUTTON_HELP = 127, + ACTION_BUTTON_HOME = 126, + ACTION_BUTTON_INFORMATION = 128, + ACTION_BUTTON_MOVIE = 136, + ACTION_BUTTON_RETURN = 133, + ACTION_BUTTON_SOUND = 135, + ARC = 25, + BALLOON = 137, + BENT_ARROW = 41, + BENT_UP_ARROW = 44, + BEVEL = 15, + BLOCK_ARC = 20, + CAN = 13, + CHART_PLUS = 182, + CHART_STAR = 181, + CHART_X = 180, + CHEVRON = 52, + CHORD = 161, + CIRCULAR_ARROW = 60, + CLOUD = 179, + CLOUD_CALLOUT = 108, + CORNER = 162, + CORNER_TABS = 169, + CROSS = 11, + CUBE = 14, + CURVED_DOWN_ARROW = 48, + CURVED_DOWN_RIBBON = 100, + CURVED_LEFT_ARROW = 46, + CURVED_RIGHT_ARROW = 45, + CURVED_UP_ARROW = 47, + CURVED_UP_RIBBON = 99, + DECAGON = 144, + DIAGONAL_STRIPE = 141, + DIAMOND = 4, + DODECAGON = 146, + DONUT = 18, + DOUBLE_BRACE = 27, + DOUBLE_BRACKET = 26, + DOUBLE_WAVE = 104, + DOWN_ARROW = 36, + DOWN_ARROW_CALLOUT = 56, + DOWN_RIBBON = 98, + EXPLOSION1 = 89, + EXPLOSION2 = 90, + FLOWCHART_ALTERNATE_PROCESS = 62, + FLOWCHART_CARD = 75, + FLOWCHART_COLLATE = 79, + FLOWCHART_CONNECTOR = 73, + FLOWCHART_DATA = 64, + FLOWCHART_DECISION = 63, + FLOWCHART_DELAY = 84, + FLOWCHART_DIRECT_ACCESS_STORAGE = 87, + FLOWCHART_DISPLAY = 88, + FLOWCHART_DOCUMENT = 67, + FLOWCHART_EXTRACT = 81, + FLOWCHART_INTERNAL_STORAGE = 66, + FLOWCHART_MAGNETIC_DISK = 86, + FLOWCHART_MANUAL_INPUT = 71, + FLOWCHART_MANUAL_OPERATION = 72, + FLOWCHART_MERGE = 82, + FLOWCHART_MULTIDOCUMENT = 68, + FLOWCHART_OFFLINE_STORAGE = 139, + FLOWCHART_OFFPAGE_CONNECTOR = 74, + FLOWCHART_OR = 78, + FLOWCHART_PREDEFINED_PROCESS = 65, + FLOWCHART_PREPARATION = 70, + FLOWCHART_PROCESS = 61, + FLOWCHART_PUNCHED_TAPE = 76, + FLOWCHART_SEQUENTIAL_ACCESS_STORAGE = 85, + FLOWCHART_SORT = 80, + FLOWCHART_STORED_DATA = 83, + FLOWCHART_SUMMING_JUNCTION = 77, + FLOWCHART_TERMINATOR = 69, + FOLDED_CORNER = 16, + FRAME = 158, + FUNNEL = 174, + GEAR_6 = 172, + GEAR_9 = 173, + HALF_FRAME = 159, + HEART = 21, + HEPTAGON = 145, + HEXAGON = 10, + HORIZONTAL_SCROLL = 102, + ISOSCELES_TRIANGLE = 7, + LEFT_ARROW = 34, + LEFT_ARROW_CALLOUT = 54, + LEFT_BRACE = 31, + LEFT_BRACKET = 29, + LEFT_CIRCULAR_ARROW = 176, + LEFT_RIGHT_ARROW = 37, + LEFT_RIGHT_ARROW_CALLOUT = 57, + LEFT_RIGHT_CIRCULAR_ARROW = 177, + LEFT_RIGHT_RIBBON = 140, + LEFT_RIGHT_UP_ARROW = 40, + LEFT_UP_ARROW = 43, + LIGHTNING_BOLT = 22, + LINE_CALLOUT_1 = 109, + LINE_CALLOUT_1_ACCENT_BAR = 113, + LINE_CALLOUT_1_BORDER_AND_ACCENT_BAR = 121, + LINE_CALLOUT_1_NO_BORDER = 117, + LINE_CALLOUT_2 = 110, + LINE_CALLOUT_2_ACCENT_BAR = 114, + LINE_CALLOUT_2_BORDER_AND_ACCENT_BAR = 122, + LINE_CALLOUT_2_NO_BORDER = 118, + LINE_CALLOUT_3 = 111, + LINE_CALLOUT_3_ACCENT_BAR = 115, + LINE_CALLOUT_3_BORDER_AND_ACCENT_BAR = 123, + LINE_CALLOUT_3_NO_BORDER = 119, + LINE_CALLOUT_4 = 112, + LINE_CALLOUT_4_ACCENT_BAR = 116, + LINE_CALLOUT_4_BORDER_AND_ACCENT_BAR = 124, + LINE_CALLOUT_4_NO_BORDER = 120, + LINE_INVERSE = 183, + MATH_DIVIDE = 166, + MATH_EQUAL = 167, + MATH_MINUS = 164, + MATH_MULTIPLY = 165, + MATH_NOT_EQUAL = 168, + MATH_PLUS = 163, + MOON = 24, + NON_ISOSCELES_TRAPEZOID = 143, + NOTCHED_RIGHT_ARROW = 50, + NO_SYMBOL = 19, + OCTAGON = 6, + OVAL = 9, + OVAL_CALLOUT = 107, + PARALLELOGRAM = 2, + PENTAGON = 51, + PIE = 142, + PIE_WEDGE = 175, + PLAQUE = 28, + PLAQUE_TABS = 171, + QUAD_ARROW = 39, + QUAD_ARROW_CALLOUT = 59, + RECTANGLE = 1, + RECTANGULAR_CALLOUT = 105, + REGULAR_PENTAGON = 12, + RIGHT_ARROW = 33, + RIGHT_ARROW_CALLOUT = 53, + RIGHT_BRACE = 32, + RIGHT_BRACKET = 30, + RIGHT_TRIANGLE = 8, + ROUNDED_RECTANGLE = 5, + ROUNDED_RECTANGULAR_CALLOUT = 106, + ROUND_1_RECTANGLE = 151, + ROUND_2_DIAG_RECTANGLE = 153, + ROUND_2_SAME_RECTANGLE = 152, + SMILEY_FACE = 17, + SNIP_1_RECTANGLE = 155, + SNIP_2_DIAG_RECTANGLE = 157, + SNIP_2_SAME_RECTANGLE = 156, + SNIP_ROUND_RECTANGLE = 154, + SQUARE_TABS = 170, + STAR_10_POINT = 149, + STAR_12_POINT = 150, + STAR_16_POINT = 94, + STAR_24_POINT = 95, + STAR_32_POINT = 96, + STAR_4_POINT = 91, + STAR_5_POINT = 92, + STAR_6_POINT = 147, + STAR_7_POINT = 148, + STAR_8_POINT = 93, + STRIPED_RIGHT_ARROW = 49, + SUN = 23, + SWOOSH_ARROW = 178, + TEAR = 160, + TRAPEZOID = 3, + UP_ARROW = 35, + UP_ARROW_CALLOUT = 55, + UP_DOWN_ARROW = 38, + UP_DOWN_ARROW_CALLOUT = 58, + UP_RIBBON = 97, + U_TURN_ARROW = 42, + VERTICAL_SCROLL = 101, + WAVE = 103 +} + +export enum PptxConnectorType { + CURVE = 3, + ELBOW = 2, + STRAIGHT = 1, + MIXED = -2 +} + export interface PptxSpacingModel { - top?: number; - bottom?: number; - left?: number; - right?: number; + top: number; + bottom: number; + left: number; + right: number; } export interface PptxPositionModel { - left?: number; - top?: number; - width?: number; - height?: number; + left: number; + top: number; + width: number; + height: number; } export interface PptxFontModel { - name?: string; - size?: number; - bold?: boolean; - italic?: boolean; - color?: string; + name: string; + size: number; + bold: boolean; + italic: boolean; + color: string; } export interface PptxFillModel { @@ -42,10 +245,10 @@ export interface PptxStrokeModel { export interface PptxShadowModel { radius: number; - offset?: number; - color?: string; - opacity?: number; - angle?: number; + offset: number; + color: string; + opacity: number; + angle: number; } export interface PptxTextRunModel { @@ -55,7 +258,7 @@ export interface PptxTextRunModel { export interface PptxParagraphModel { spacing?: PptxSpacingModel; - alignment?: any; + alignment?: PptxAlignment; font?: PptxFontModel; text?: string; text_runs?: PptxTextRunModel[]; @@ -78,18 +281,18 @@ export interface PptxTextBoxModel extends PptxShapeModel { margin?: PptxSpacingModel; fill?: PptxFillModel; position: PptxPositionModel; - text_wrap?: boolean; + text_wrap: boolean; paragraphs: PptxParagraphModel[]; } export interface PptxAutoShapeBoxModel extends PptxShapeModel { - type?: any; + type?: PptxShapeType; margin?: PptxSpacingModel; fill?: PptxFillModel; stroke?: PptxStrokeModel; shadow?: PptxShadowModel; position: PptxPositionModel; - text_wrap?: boolean; + text_wrap: boolean; border_radius?: number; paragraphs?: PptxParagraphModel[]; } @@ -97,7 +300,7 @@ export interface PptxAutoShapeBoxModel extends PptxShapeModel { export interface PptxPictureBoxModel extends PptxShapeModel { position: PptxPositionModel; margin?: PptxSpacingModel; - clip?: boolean; + clip: boolean; overlay?: string; border_radius?: number[]; shape?: PptxBoxShapeEnum; @@ -106,19 +309,19 @@ export interface PptxPictureBoxModel extends PptxShapeModel { } export interface PptxConnectorModel extends PptxShapeModel { - type?: any; + type?: PptxConnectorType; position: PptxPositionModel; - thickness?: number; - color?: string; + thickness: number; + color: string; } - export interface PptxSlideModel { background?: PptxFillModel; shapes: (PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel)[]; } export interface PptxPresentationModel { + name?: string; shapes?: PptxShapeModel[]; slides: PptxSlideModel[]; } @@ -138,14 +341,14 @@ export const createPptxPositionForTextbox = (left: number, top: number, width: n }); export const positionToPtList = (position: PptxPositionModel): number[] => { - return [position.left || 0, position.top || 0, position.width || 0, position.height || 0]; + return [position.left, position.top, position.width, position.height]; }; export const positionToPtXyxy = (position: PptxPositionModel): number[] => { - const left = position.left || 0; - const top = position.top || 0; - const width = position.width || 0; - const height = position.height || 0; + const left = position.left; + const top = position.top; + const width = position.width; + const height = position.height; return [left, top, left + width, top + height]; }; diff --git a/servers/nextjs/utils/pptx_models_utils.ts b/servers/nextjs/utils/pptx_models_utils.ts index e209a084..015cd522 100644 --- a/servers/nextjs/utils/pptx_models_utils.ts +++ b/servers/nextjs/utils/pptx_models_utils.ts @@ -15,9 +15,32 @@ import { PptxPictureModel, PptxObjectFitModel, PptxBoxShapeEnum, - PptxObjectFitEnum + PptxObjectFitEnum, + PptxAlignment, + PptxShapeType, + PptxConnectorType } from "@/types/pptx_models"; +/** + * Converts text alignment string to PptxAlignment enum value + */ +function convertTextAlignToPptxAlignment(textAlign?: string): PptxAlignment | undefined { + if (!textAlign) return undefined; + + switch (textAlign.toLowerCase()) { + case 'left': + return PptxAlignment.LEFT; + case 'center': + return PptxAlignment.CENTER; + case 'right': + return PptxAlignment.RIGHT; + case 'justify': + return PptxAlignment.JUSTIFY; + default: + return PptxAlignment.LEFT; + } +} + /** * Converts ElementAttributes[][] to PptxSlideModel[] * Each inner array represents elements on a slide @@ -38,7 +61,7 @@ export function convertElementAttributesToPptxSlides( // Add background color if available if (backgroundColors && backgroundColors[index]) { slide.background = { - color: backgroundColors[index] + color: backgroundColors[index]! }; } @@ -58,7 +81,7 @@ function convertElementToPptxShape( } // Check if it's an image element - if (element.tagName === 'img' || element.className?.includes('image')) { + if (element.tagName === 'img' || (element.className && typeof element.className === 'string' && element.className.includes('image'))) { return convertToPictureBox(element); } @@ -68,7 +91,7 @@ function convertElementToPptxShape( } // Check if it's a connector/line element - if (element.tagName === 'hr' || element.className?.includes('connector') || element.className?.includes('line')) { + if (element.tagName === 'hr' || (element.className && typeof element.className === 'string' && (element.className.includes('connector') || element.className.includes('line')))) { return convertToConnector(element); } @@ -81,34 +104,36 @@ function convertElementToPptxShape( */ function convertToTextBox(element: ElementAttributes): PptxTextBoxModel { const position: PptxPositionModel = { - left: element.position?.left, - top: element.position?.top, - width: element.position?.width, - height: element.position?.height + left: Math.round(element.position?.left ?? 0), + top: Math.round(element.position?.top ?? 0), + width: Math.round(element.position?.width ?? 0), + height: Math.round(element.position?.height ?? 0) }; const margin: PptxSpacingModel | undefined = element.margin ? { - top: element.margin.top, - bottom: element.margin.bottom, - left: element.margin.left, - right: element.margin.right + top: Math.round(element.margin.top ?? 0), + bottom: Math.round(element.margin.bottom ?? 0), + left: Math.round(element.margin.left ?? 0), + right: Math.round(element.margin.right ?? 0) } : undefined; - const fill: PptxFillModel | undefined = element.background?.color ? { - color: element.background.color - } : undefined; + const fill: PptxFillModel | undefined = element.background?.color ? + (element.background.isInherited ? + (element.shadow?.color ? { color: element.background.color } : undefined) : + { color: element.background.color } + ) : undefined; const font: PptxFontModel | undefined = element.font ? { - name: element.font.name, - size: element.font.size, - bold: element.font.weight ? element.font.weight >= 600 : undefined, - italic: element.font.italic, - color: element.font.color + name: element.font.name ?? "Inter", + size: Math.round(element.font.size ?? 16), + bold: element.font.weight ? element.font.weight >= 600 : false, + italic: element.font.italic ?? false, + color: element.font.color ?? "000000" } : undefined; const paragraph: PptxParagraphModel = { spacing: undefined, - alignment: element.textAlign, + alignment: convertTextAlignToPptxAlignment(element.textAlign), font, text: element.innerText }; @@ -127,58 +152,61 @@ function convertToTextBox(element: ElementAttributes): PptxTextBoxModel { */ function convertToAutoShapeBox(element: ElementAttributes): PptxAutoShapeBoxModel { const position: PptxPositionModel = { - left: element.position?.left, - top: element.position?.top, - width: element.position?.width, - height: element.position?.height + left: Math.round(element.position?.left ?? 0), + top: Math.round(element.position?.top ?? 0), + width: Math.round(element.position?.width ?? 0), + height: Math.round(element.position?.height ?? 0) }; const margin: PptxSpacingModel | undefined = element.margin ? { - top: element.margin.top, - bottom: element.margin.bottom, - left: element.margin.left, - right: element.margin.right + top: Math.round(element.margin.top ?? 0), + bottom: Math.round(element.margin.bottom ?? 0), + left: Math.round(element.margin.left ?? 0), + right: Math.round(element.margin.right ?? 0) } : undefined; - const fill: PptxFillModel | undefined = element.background?.color ? { - color: element.background.color - } : undefined; + const fill: PptxFillModel | undefined = element.background?.color ? + (element.background.isInherited ? + (element.shadow?.color ? { color: element.background.color } : undefined) : + { color: element.background.color } + ) : undefined; const stroke: PptxStrokeModel | undefined = element.border?.color ? { color: element.border.color, - thickness: element.border.width || 1 + thickness: element.border.width ?? 1 // float - keep as number } : undefined; const shadow: PptxShadowModel | undefined = element.shadow?.color ? { - radius: element.shadow.radius ?? 4, - offset: element.shadow.offset ? Math.sqrt(element.shadow.offset[0] ** 2 + element.shadow.offset[1] ** 2) : undefined, + radius: Math.round(element.shadow.radius ?? 4), // int + offset: Math.round(element.shadow.offset ? Math.sqrt(element.shadow.offset[0] ** 2 + element.shadow.offset[1] ** 2) : 0), // int color: element.shadow.color, - opacity: element.shadow.opacity, - angle: element.shadow.angle + opacity: element.shadow.opacity ?? 0.5, // float - keep as number + angle: Math.round(element.shadow.angle ?? 0) // int } : undefined; // Check if element has text content const paragraphs: PptxParagraphModel[] | undefined = element.innerText ? [{ spacing: undefined, - alignment: element.textAlign, + alignment: convertTextAlignToPptxAlignment(element.textAlign), font: element.font ? { - name: element.font.name, - size: element.font.size, - bold: element.font.weight ? element.font.weight >= 600 : undefined, - italic: element.font.italic, - color: element.font.color + name: element.font.name ?? "Inter", + size: Math.round(element.font.size ?? 16), // int + bold: element.font.weight ? element.font.weight >= 600 : false, + italic: element.font.italic ?? false, + color: element.font.color ?? "000000" } : undefined, text: element.innerText }] : undefined; return { + type: PptxShapeType.ROUNDED_RECTANGLE, // Default to rounded rectangle margin, fill, stroke, shadow, position, text_wrap: element.textWrap ?? true, - border_radius: element.borderRadius ? (Array.isArray(element.borderRadius) ? element.borderRadius[0] : element.borderRadius) : 0, + border_radius: element.borderRadius ? Math.round(element.borderRadius[0]) : 0, // int - use first value for autoshape paragraphs }; } @@ -188,17 +216,17 @@ function convertToAutoShapeBox(element: ElementAttributes): PptxAutoShapeBoxMode */ function convertToPictureBox(element: ElementAttributes): PptxPictureBoxModel { const position: PptxPositionModel = { - left: element.position?.left, - top: element.position?.top, - width: element.position?.width, - height: element.position?.height + left: Math.round(element.position?.left ?? 0), + top: Math.round(element.position?.top ?? 0), + width: Math.round(element.position?.width ?? 0), + height: Math.round(element.position?.height ?? 0) }; const margin: PptxSpacingModel | undefined = element.margin ? { - top: element.margin.top, - bottom: element.margin.bottom, - left: element.margin.left, - right: element.margin.right + top: Math.round(element.margin.top ?? 0), + bottom: Math.round(element.margin.bottom ?? 0), + left: Math.round(element.margin.left ?? 0), + right: Math.round(element.margin.right ?? 0) } : undefined; const objectFit: PptxObjectFitModel = { @@ -214,9 +242,9 @@ function convertToPictureBox(element: ElementAttributes): PptxPictureBoxModel { return { position, margin, - clip: element.clip ?? false, + clip: element.clip ?? true, overlay: element.overlay, - border_radius: element.borderRadius ? (Array.isArray(element.borderRadius) ? element.borderRadius : [element.borderRadius]) : undefined, + border_radius: element.borderRadius ? element.borderRadius.map(r => Math.round(r)) : undefined, // List[int] - 4 elements from route parsing shape: element.shape ? (element.shape as PptxBoxShapeEnum) : PptxBoxShapeEnum.RECTANGLE, object_fit: objectFit, picture @@ -228,16 +256,16 @@ function convertToPictureBox(element: ElementAttributes): PptxPictureBoxModel { */ function convertToConnector(element: ElementAttributes): PptxConnectorModel { const position: PptxPositionModel = { - left: element.position?.left, - top: element.position?.top, - width: element.position?.width, - height: element.position?.height + left: Math.round(element.position?.left ?? 0), + top: Math.round(element.position?.top ?? 0), + width: Math.round(element.position?.width ?? 0), + height: Math.round(element.position?.height ?? 0) }; return { - type: element.connectorType, + type: PptxConnectorType.STRAIGHT, // Default to straight connector position, - thickness: element.border?.width || 1, - color: element.border?.color || element.background?.color || '#000000' + thickness: element.border?.width ?? 0.5, // float - keep as number + color: element.border?.color || element.background?.color || '000000' }; }