feat(nextjs): somewhat working presentation export
This commit is contained in:
parent
6ae502fc9e
commit
c760736e51
11 changed files with 877 additions and 714 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
1
servers/nextjs/test-shadow-formats.html
Normal file
1
servers/nextjs/test-shadow-formats.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue