feat(nextjs): somewhat working presentation export

This commit is contained in:
sauravniraula 2025-07-19 17:05:18 +05:45
parent 6ae502fc9e
commit c760736e51
No known key found for this signature in database
GPG key ID: 60FCC1B5A5E83326
11 changed files with 877 additions and 714 deletions

View file

@ -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

View file

@ -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"
)

View file

@ -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

View file

@ -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<PptxPresentationModel> => {
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");
}

View file

@ -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(),

View file

@ -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<Element>[]) {
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<Element>[]) {
}
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<ElementHandle<Element>> {
const page = await getPresentationPage(id);
async function getSlidesWrapper(browser: Browser, id: string): Promise<ElementHandle<Element>> {
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<ElementHandle<Element>> {
}
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<Element>): Promise<SlideAttributesResult> {
// 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<Element>): 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<Element>): 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<Element>): 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<Element>): 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<Element>): 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<Element>): P
async function getElementAttributes(element: ElementHandle<Element>): Promise<ElementAttributes> {
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<Element>): Promise<El
return true;
}
const computedStyles = window.getComputedStyle(el);
function parsePosition(el: Element) {
const rect = el.getBoundingClientRect();
return {
left: isFinite(rect.left) ? rect.left : 0,
top: isFinite(rect.top) ? rect.top : 0,
width: isFinite(rect.width) ? rect.width : 0,
height: isFinite(rect.height) ? rect.height : 0,
};
}
// Parse position and dimensions
const rect = el.getBoundingClientRect();
const position = {
left: isFinite(rect.left) ? rect.left : 0,
top: isFinite(rect.top) ? rect.top : 0,
width: isFinite(rect.width) ? rect.width : 0,
height: isFinite(rect.height) ? rect.height : 0,
};
// Parse background
const backgroundColor = colorToHex(computedStyles.backgroundColor);
const backgroundOpacity = parseFloat(computedStyles.opacity);
const background = {
color: backgroundColor,
opacity: isNaN(backgroundOpacity) ? undefined : backgroundOpacity,
};
// Parse border
const borderColor = colorToHex(computedStyles.borderColor);
const borderWidth = parseFloat(computedStyles.borderWidth);
const border = borderWidth === 0 ? undefined : {
color: borderColor,
width: isNaN(borderWidth) ? undefined : borderWidth,
};
// Parse shadow (box-shadow)
const boxShadow = computedStyles.boxShadow;
let shadow = {
offset: undefined as [number, number] | undefined,
color: undefined as string | undefined,
opacity: undefined as number | undefined,
radius: undefined as number | undefined,
angle: undefined as number | undefined,
};
if (boxShadow && boxShadow !== 'none') {
const shadowParts = boxShadow.split(' ');
if (shadowParts.length >= 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;
}

View file

@ -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<SlideMetadata[]> {
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();
}
}

View file

@ -0,0 +1 @@

View file

@ -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;

View file

@ -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];
};

View file

@ -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'
};
}