Merge branch 'feat/custom_schema_and_layout' of github.com:presenton/presenton into feat/custom_schema_and_layout

This commit is contained in:
shiva raj badu 2025-07-20 00:38:42 +05:45
commit daa8fa3e41
No known key found for this signature in database
10 changed files with 295 additions and 22 deletions

24
package-lock.json generated Normal file
View file

@ -0,0 +1,24 @@
{
"name": "presenton",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"uuid": "^11.1.0"
}
},
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"bin": {
"uuid": "dist/esm/bin/uuid"
}
}
}
}

5
package.json Normal file
View file

@ -0,0 +1,5 @@
{
"dependencies": {
"uuid": "^11.1.0"
}
}

View file

@ -61,11 +61,13 @@ class PptxFontModel(BaseModel):
class PptxFillModel(BaseModel):
color: str
opacity: float = 1.0
class PptxStrokeModel(BaseModel):
color: str
thickness: float
opacity: float = 1.0
class PptxShadowModel(BaseModel):
@ -85,6 +87,7 @@ class PptxParagraphModel(BaseModel):
spacing: Optional[PptxSpacingModel] = None
alignment: Optional[PP_ALIGN] = None
font: Optional[PptxFontModel] = None
line_height: Optional[float] = None
text: Optional[str] = None
text_runs: Optional[List[PptxTextRunModel]] = None
@ -141,6 +144,7 @@ class PptxConnectorModel(PptxShapeModel):
position: PptxPositionModel
thickness: float = 0.5
color: str = "000000"
opacity: float = 1.0
class PptxSlideModel(BaseModel):

View file

@ -10,6 +10,7 @@ from pptx.text.text import _Paragraph, TextFrame, Font, _Run
from pptx.opc.constants import RELATIONSHIP_TYPE as RT
from lxml.etree import fromstring, tostring
from PIL import Image
from pptx.oxml.xmlchemy import OxmlElement
from pptx.util import Pt
from pptx.dml.color import RGBColor
@ -55,6 +56,13 @@ class PptxPresentationCreator:
self._ppt.slide_width = Pt(1280)
self._ppt.slide_height = Pt(720)
def get_sub_element(self, parent, tagname, **kwargs):
"""Helper method to create XML elements"""
element = OxmlElement(tagname)
element.attrib.update(kwargs)
parent.append(element)
return element
async def fetch_network_assets(self):
image_urls = []
models_with_network_asset: List[PptxPictureBoxModel] = []
@ -158,6 +166,8 @@ class PptxPresentationCreator:
)
connector_shape.line.width = Pt(connector_model.thickness)
connector_shape.line.color.rgb = RGBColor.from_string(connector_model.color)
# Set line opacity using XML manipulation for better reliability
self.set_line_opacity(connector_shape, connector_model.opacity)
def add_picture(self, slide: Slide, picture_model: PptxPictureBoxModel):
image_path = picture_model.picture.path
@ -252,6 +262,9 @@ class PptxPresentationCreator:
if paragraph_model.spacing:
self.apply_spacing_to_paragraph(paragraph, paragraph_model.spacing)
if paragraph_model.line_height:
paragraph.line_spacing = paragraph_model.line_height
if paragraph_model.alignment:
paragraph.alignment = paragraph_model.alignment
@ -365,6 +378,7 @@ class PptxPresentationCreator:
else:
shape.fill.solid()
shape.fill.fore_color.rgb = RGBColor.from_string(fill.color)
self.set_fill_opacity(shape.fill, fill.opacity)
def apply_stroke_to_shape(
self, shape: Shape, stroke: Optional[PptxStrokeModel] = None
@ -375,6 +389,7 @@ class PptxPresentationCreator:
shape.line.fill.solid()
shape.line.fill.fore_color.rgb = RGBColor.from_string(stroke.color)
shape.line.width = Pt(stroke.thickness)
self.set_fill_opacity(shape.line.fill, stroke.opacity)
def apply_shadow_to_shape(
self, shape: Shape, shadow: Optional[PptxShadowModel] = None
@ -427,6 +442,19 @@ class PptxPresentationCreator:
nsmap=nsmap,
)
def set_fill_opacity(self, fill, opacity):
if opacity is None or opacity >= 1.0:
return
alpha = int((opacity) * 100000)
try:
ts = fill._xPr.solidFill
sF = ts.get_or_change_to_srgbClr()
self.get_sub_element(sF, "a:alpha", val=str(alpha))
except Exception as e:
print(f"Could not set fill opacity: {e}")
def get_margined_position(
self, position: PptxPositionModel, margin: Optional[PptxSpacingModel]
) -> PptxPositionModel:

View file

@ -4,6 +4,9 @@ import puppeteer, { Browser, ElementHandle } from "puppeteer";
import { ElementAttributes, SlideAttributesResult } from "@/types/element_attibutes";
import { convertElementAttributesToPptxSlides } from "@/utils/pptx_models_utils";
import { PptxPresentationModel } from "@/types/pptx_models";
import fs from "fs";
import path from "path";
import crypto from "crypto";
// Interface for getAllChildElementsAttributes function arguments
interface GetAllChildElementsAttributesArgs {
@ -57,9 +60,13 @@ async function getPresentationId(request: NextRequest) {
}
async function getSlidesAttributes(slides: ElementHandle<Element>[]) {
const slideResults = await Promise.all(slides.map(async (slide) => {
return await getAllChildElementsAttributes({ element: slide });
}));
const slideResults: SlideAttributesResult[] = [];
//? Can't use Promise.all because of the screenshot
//? taking screenshot with mess up position of elements
for (const slide of slides) {
const result = await getAllChildElementsAttributes({ element: slide });
slideResults.push(result);
}
const elements = slideResults.map(result => result.elements);
const backgroundColors = slideResults.map(result => result.backgroundColor);
@ -93,9 +100,10 @@ async function getPresentationPage(browser: Browser, id: string) {
page.on('console', (msg) => {
const type = msg.type();
const text = msg.text();
console.log(`${type}: ${text}`);
});
await page.setViewport({ width: 1640, height: 720, deviceScaleFactor: 1 });
await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 });
await page.goto(`http://localhost/presentation?id=${id}`, {
waitUntil: "networkidle0",
timeout: 60000,
@ -116,6 +124,42 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth =
};
});
// Check if this element is SVG or canvas or table
const tagName = await element.evaluate((el) => el.tagName.toLowerCase());
if (tagName === 'svg' || tagName === 'canvas' || tagName === 'table') {
return {
elements: [],
backgroundColor: undefined
};
// // Take screenshot of SVG/canvas element
// const screenshotPath = await takeElementScreenshot(element);
// // Get basic attributes for the element
// const attributes = await getElementAttributes(element);
// // Update image source to point to the screenshot
// if (screenshotPath) {
// attributes.imageSrc = screenshotPath;
// }
// // Adjust position relative to root
// if (attributes.position && attributes.position.left !== undefined && attributes.position.top !== undefined) {
// attributes.position = {
// left: attributes.position.left - currentRootRect.left,
// top: attributes.position.top - currentRootRect.top,
// width: attributes.position.width,
// height: attributes.position.height,
// };
// }
// // Return early without processing children for SVG/canvas elements
// return {
// elements: [attributes],
// backgroundColor: undefined
// };
}
// Get direct children only (not all descendants)
const directChildElementHandles = await element.$$(':scope > *');
@ -232,7 +276,6 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth =
}
// Do not edit this function, it is used to get the attributes of an element
async function getElementAttributes(element: ElementHandle<Element>): Promise<ElementAttributes> {
const attributes = await element.evaluate((el: Element) => {
@ -333,6 +376,7 @@ async function getElementAttributes(element: ElementHandle<Element>): Promise<El
return borderWidth === 0 ? undefined : {
color: borderColorResult.hex,
width: isNaN(borderWidth) ? undefined : borderWidth,
opacity: borderColorResult.opacity,
};
}
@ -384,7 +428,6 @@ async function getElementAttributes(element: ElementHandle<Element>): Promise<El
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(' ');
@ -582,6 +625,38 @@ async function getElementAttributes(element: ElementHandle<Element>): Promise<El
};
}
function parseLineHeight(computedStyles: CSSStyleDeclaration, el: Element) {
const lineHeight = computedStyles.lineHeight;
const innerText = el.textContent || '';
// Check if text is multiline by looking for newline characters or checking if text wraps due to bounds
const htmlEl = el as HTMLElement;
// Get font size for comparison
const fontSize = parseFloat(computedStyles.fontSize);
const computedLineHeight = parseFloat(computedStyles.lineHeight);
// Estimate single line height (use computed line height if available, otherwise use font size * 1.2)
const singleLineHeight = !isNaN(computedLineHeight) ? computedLineHeight : fontSize * 1.2;
// Check for multiline text
const hasExplicitLineBreaks = innerText.includes('\n') || innerText.includes('\r') || innerText.includes('\r\n');
const hasTextWrapping = htmlEl.offsetHeight > singleLineHeight * 2; // Allow some tolerance
const hasOverflow = htmlEl.scrollHeight > htmlEl.clientHeight;
const isMultiline = hasExplicitLineBreaks || hasTextWrapping || hasOverflow;
// Only return line height if text is multiline
if (isMultiline && lineHeight && lineHeight !== 'normal') {
const parsedLineHeight = parseFloat(lineHeight);
if (!isNaN(parsedLineHeight)) {
return parsedLineHeight;
}
}
return undefined;
}
function parseMargin(computedStyles: CSSStyleDeclaration) {
const marginTop = parseFloat(computedStyles.marginTop);
const marginBottom = parseFloat(computedStyles.marginBottom);
@ -657,6 +732,8 @@ async function getElementAttributes(element: ElementHandle<Element>): Promise<El
const font = parseFont(computedStyles);
const lineHeight = parseLineHeight(computedStyles, el);
const margin = parseMargin(computedStyles);
const padding = parsePadding(computedStyles);
@ -690,6 +767,7 @@ async function getElementAttributes(element: ElementHandle<Element>): Promise<El
padding,
zIndex: zIndexValue,
textAlign: textAlign !== 'left' ? textAlign : undefined,
lineHeight,
borderRadius: borderRadiusValue,
imageSrc: imageSrc || undefined,
objectFit,
@ -705,3 +783,91 @@ async function getElementAttributes(element: ElementHandle<Element>): Promise<El
});
return attributes;
}
async function takeElementScreenshot(element: ElementHandle<Element>): Promise<string | undefined> {
try {
// Validate environment configuration
const tempDir = process.env.TEMP_DIRECTORY;
if (!tempDir) {
console.warn('TEMP_DIRECTORY environment variable not set, skipping screenshot');
return undefined;
}
// Check element visibility and dimensions
const elementInfo = await element.evaluate((el) => {
const rect = el.getBoundingClientRect();
const styles = window.getComputedStyle(el);
return {
isVisible: styles.visibility !== 'hidden' &&
styles.display !== 'none' &&
styles.opacity !== '0',
hasValidDimensions: rect.width > 0 && rect.height > 0,
isInViewport: rect.top < window.innerHeight &&
rect.bottom > 0 &&
rect.left < window.innerWidth &&
rect.right > 0,
dimensions: {
width: rect.width,
height: rect.height,
top: rect.top,
left: rect.left
}
};
}).catch((error) => {
console.warn('Failed to evaluate element visibility:', error.message);
return { isVisible: false, hasValidDimensions: false, isInViewport: false, dimensions: null };
});
if (!elementInfo.isVisible || !elementInfo.hasValidDimensions) {
console.warn('Element is not visible or has invalid dimensions, skipping screenshot', {
visible: elementInfo.isVisible,
validDimensions: elementInfo.hasValidDimensions,
dimensions: elementInfo.dimensions
});
return undefined;
}
// Scroll element into viewport if not visible
if (!elementInfo.isInViewport) {
try {
await element.evaluate((el) => {
el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'center' });
});
// Wait a brief moment for scrolling to complete
await new Promise(resolve => setTimeout(resolve, 200));
console.log('Element scrolled into viewport for screenshot');
} catch (scrollError: any) {
console.warn('Failed to scroll element into view:', scrollError.message);
// Continue with screenshot attempt even if scrolling fails
}
}
// Ensure screenshots directory exists
const screenshotsDir = path.join(tempDir, 'screenshots');
if (!fs.existsSync(screenshotsDir)) {
fs.mkdirSync(screenshotsDir, { recursive: true });
}
// Generate unique filename
const uuid = crypto.randomUUID();
const filename = `${uuid}.png`;
const filePath = path.join(screenshotsDir, filename);
// Take screenshot of the element
await element.screenshot({
path: filePath as `${string}.png`,
type: 'png',
omitBackground: false
});
console.log(`Screenshot saved: ${filePath}`);
return filePath;
} catch (error) {
console.error('Error taking element screenshot:', error);
return undefined;
}
}

View file

@ -64,6 +64,7 @@
"@types/puppeteer": "^5.4.7",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/uuid": "^10.0.0",
"cypress": "^14.3.3",
"tailwindcss": "^3.4.1",
"typescript": "^5"
@ -152,6 +153,15 @@
"node": ">= 6"
}
},
"node_modules/@cypress/request/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@cypress/xvfb": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz",
@ -2386,6 +2396,12 @@
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
@ -7381,16 +7397,6 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/verror": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",

View file

@ -67,6 +67,7 @@
"@types/puppeteer": "^5.4.7",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/uuid": "^10.0.0",
"cypress": "^14.3.3",
"tailwindcss": "^3.4.1",
"typescript": "^5"

View file

@ -10,6 +10,7 @@ export interface ElementAttributes {
border?: {
color?: string;
width?: number;
opacity?: number;
};
shadow?: {
offset?: [number, number];
@ -47,6 +48,7 @@ export interface ElementAttributes {
};
zIndex?: number;
textAlign?: 'left' | 'center' | 'right' | 'justify';
lineHeight?: number;
borderRadius?: number[];
imageSrc?: string;
objectFit?: 'contain' | 'cover' | 'fill';

View file

@ -236,11 +236,13 @@ export interface PptxFontModel {
export interface PptxFillModel {
color: string;
opacity: number;
}
export interface PptxStrokeModel {
color: string;
thickness: number;
opacity: number;
}
export interface PptxShadowModel {
@ -260,6 +262,7 @@ export interface PptxParagraphModel {
spacing?: PptxSpacingModel;
alignment?: PptxAlignment;
font?: PptxFontModel;
line_height?: number;
text?: string;
text_runs?: PptxTextRunModel[];
}
@ -313,6 +316,7 @@ export interface PptxConnectorModel extends PptxShapeModel {
position: PptxPositionModel;
thickness: number;
color: string;
opacity: number;
}
export interface PptxSlideModel {

View file

@ -40,6 +40,28 @@ function convertTextAlignToPptxAlignment(textAlign?: string): PptxAlignment | un
}
}
/**
* Converts line height from pixels to relative format (e.g., 1.5)
* If lineHeight is already a relative number (less than 10), return as is
* Otherwise, convert from pixels to relative by dividing by font size
*/
function convertLineHeightToRelative(lineHeight?: number, fontSize?: number): number | undefined {
if (!lineHeight) return undefined;
let calculatedLineHeight = 1.2;
// If lineHeight is already a relative number (typically between 1.0 and 3.0)
if (lineHeight < 10) {
calculatedLineHeight = lineHeight;
}
// If we have font size, convert from pixels to relative
if (fontSize && fontSize > 0) {
calculatedLineHeight = Math.round((lineHeight / fontSize) * 100) / 100; // Round to 2 decimal places
}
return calculatedLineHeight - 0.4 + (fontSize ?? 16) * 0.004;
}
/**
* Converts ElementAttributes[][] to PptxSlideModel[]
* Each inner array represents elements on a slide
@ -60,7 +82,8 @@ export function convertElementAttributesToPptxSlides(
// Add background color if available
if (backgroundColors && backgroundColors[index]) {
slide.background = {
color: backgroundColors[index]!
color: backgroundColors[index]!,
opacity: 1.0
};
}
@ -80,7 +103,7 @@ function convertElementToPptxShape(
}
// Check if it's an image element
if (element.tagName === 'img' || (element.className && typeof element.className === 'string' && element.className.includes('image'))) {
if (element.tagName === 'img' || (element.className && typeof element.className === 'string' && element.className.includes('image')) || element.imageSrc) {
return convertToPictureBox(element);
}
@ -109,7 +132,10 @@ function convertToTextBox(element: ElementAttributes): PptxTextBoxModel {
height: Math.round(element.position?.height ?? 0)
};
const fill: PptxFillModel | undefined = element.background?.color ? { color: element.background.color } : undefined;
const fill: PptxFillModel | undefined = element.background?.color ? {
color: element.background.color,
opacity: element.background.opacity ?? 1.0
} : undefined;
const font: PptxFontModel | undefined = element.font ? {
name: element.font.name ?? "Inter",
@ -123,6 +149,7 @@ function convertToTextBox(element: ElementAttributes): PptxTextBoxModel {
spacing: undefined,
alignment: convertTextAlignToPptxAlignment(element.textAlign),
font,
line_height: convertLineHeightToRelative(element.lineHeight, element.font?.size),
text: element.innerText
};
@ -145,11 +172,15 @@ function convertToAutoShapeBox(element: ElementAttributes): PptxAutoShapeBoxMode
width: Math.round(element.position?.width ?? 0),
height: Math.round(element.position?.height ?? 0)
};
const fill: PptxFillModel | undefined = element.background?.color ? { color: element.background.color } : undefined;
const fill: PptxFillModel | undefined = element.background?.color ? {
color: element.background.color,
opacity: element.background.opacity ?? 1.0
} : undefined;
const stroke: PptxStrokeModel | undefined = element.border?.color ? {
color: element.border.color,
thickness: element.border.width ?? 1 // float - keep as number
thickness: element.border.width ?? 1, // float - keep as number
opacity: element.border.opacity ?? 1.0
} : undefined;
const shadow: PptxShadowModel | undefined = element.shadow?.color ? {
@ -171,6 +202,7 @@ function convertToAutoShapeBox(element: ElementAttributes): PptxAutoShapeBoxMode
italic: element.font.italic ?? false,
color: element.font.color ?? "000000"
} : undefined,
line_height: convertLineHeightToRelative(element.lineHeight, element.font?.size),
text: element.innerText
}] : undefined;
@ -235,6 +267,7 @@ function convertToConnector(element: ElementAttributes): PptxConnectorModel {
type: PptxConnectorType.STRAIGHT, // Default to straight connector
position,
thickness: element.border?.width ?? 0.5, // float - keep as number
color: element.border?.color || element.background?.color || '000000'
color: element.border?.color || element.background?.color || '000000',
opacity: element.border?.opacity ?? 1.0
};
}