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-24 00:44:48 +05:45
commit 8dfd8734c0
No known key found for this signature in database
8 changed files with 222 additions and 143 deletions

View file

@ -7,7 +7,7 @@ from utils.get_env import get_app_data_directory_env, get_database_url_env
database_url = get_database_url_env() or "sqlite:///" + os.path.join(
get_app_data_directory_env(), "fastapi.db"
get_app_data_directory_env() or "/tmp/presenton", "fastapi.db"
)
connect_args = {}
if "sqlite" in database_url:

View file

@ -404,43 +404,76 @@ class PptxPresentationCreator:
# # Remove existing shadow effects if present
effect_list = sp_pr.find("a:effectLst", namespaces=nsmap)
if effect_list:
old_shadow = effect_list.find("a:outerShdw")
if old_shadow:
old_outer_shadow = effect_list.find("a:outerShdw")
if old_outer_shadow:
effect_list.remove(
old_shadow, namespaces=nsmap
old_outer_shadow, namespaces=nsmap
) # Remove the old shadow
old_inner_shadow = effect_list.find("a:innerShdw")
if old_inner_shadow:
effect_list.remove(
old_inner_shadow, namespaces=nsmap
) # Remove the old shadow
old_prst_shadow = effect_list.find("a:prstShdw")
if old_prst_shadow:
effect_list.remove(
old_prst_shadow, namespaces=nsmap
) # Remove the old shadow
if not shadow:
return
if not effect_list:
effect_list = etree.SubElement(
sp_pr, f"{{{nsmap['a']}}}effectLst", nsmap=nsmap
)
outer_shadow = etree.SubElement(
effect_list,
f"{{{nsmap['a']}}}outerShdw",
{
"blurRad": f"{Pt(shadow.radius)}",
"dir": f"{shadow.angle * 1000}",
"dist": f"{Pt(shadow.offset)}",
"rotWithShape": "0",
},
nsmap=nsmap,
)
color_element = etree.SubElement(
outer_shadow,
f"{{{nsmap['a']}}}srgbClr",
{"val": f"{shadow.color}"},
nsmap=nsmap,
)
etree.SubElement(
color_element,
f"{{{nsmap['a']}}}alpha",
{"val": f"{int(shadow.opacity * 100000)}"},
nsmap=nsmap,
)
if shadow is None:
# Apply shadow with zero values when shadow is None
outer_shadow = etree.SubElement(
effect_list,
f"{{{nsmap['a']}}}outerShdw",
{
"blurRad": "0",
"dist": "0",
"dir": "0",
},
nsmap=nsmap,
)
color_element = etree.SubElement(
outer_shadow,
f"{{{nsmap['a']}}}srgbClr",
{"val": "000000"},
nsmap=nsmap,
)
etree.SubElement(
color_element,
f"{{{nsmap['a']}}}alpha",
{"val": "0"},
nsmap=nsmap,
)
else:
# Apply the provided shadow
outer_shadow = etree.SubElement(
effect_list,
f"{{{nsmap['a']}}}outerShdw",
{
"blurRad": f"{Pt(shadow.radius)}",
"dir": f"{shadow.angle * 1000}",
"dist": f"{Pt(shadow.offset)}",
"rotWithShape": "0",
},
nsmap=nsmap,
)
color_element = etree.SubElement(
outer_shadow,
f"{{{nsmap['a']}}}srgbClr",
{"val": f"{shadow.color}"},
nsmap=nsmap,
)
etree.SubElement(
color_element,
f"{{{nsmap['a']}}}alpha",
{"val": f"{int(shadow.opacity * 100000)}"},
nsmap=nsmap,
)
def set_fill_opacity(self, fill, opacity):
if opacity is None or opacity >= 1.0:

View file

@ -8,7 +8,7 @@ from utils.get_env import get_temp_directory_env
class TempFileService:
def __init__(self):
self.base_dir = get_temp_directory_env()
self.base_dir = get_temp_directory_env() or "/tmp/presenton"
# TODO: Uncomment this when we want to cleanup the base dir on startup
# self.cleanup_base_dir()
os.makedirs(self.base_dir, exist_ok=True)

View file

@ -0,0 +1,40 @@
import asyncio
from models.pptx_models import (
PptxAutoShapeBoxModel,
PptxFillModel,
PptxPositionModel,
PptxPresentationModel,
PptxSlideModel,
)
from services.pptx_presentation_creator import PptxPresentationCreator
from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE
pptx_model = PptxPresentationModel(
slides=[
PptxSlideModel(
shapes=[
PptxAutoShapeBoxModel(
type=MSO_AUTO_SHAPE_TYPE.RECTANGLE,
position=PptxPositionModel(
left=20,
right=20,
width=100,
height=100,
),
fill=PptxFillModel(
color="000000",
opacity=0.5,
),
)
]
)
]
)
def test_pptx_creator():
temp_dir = "/tmp/presenton"
pptx_creator = PptxPresentationCreator(pptx_model, temp_dir)
asyncio.run(pptx_creator.create_ppt())
pptx_creator.save("debug/test.pptx")

View file

@ -108,7 +108,7 @@ export function OutlineItem({
{isStreaming ? <textarea
defaultValue={slideOutline.body || ''}
onBlur={(e) => handleSlideChange({ ...slideOutline, body: e.target.value })}
className="text-sm flex-1 font-normal bg-transparent outline-none"
className="text-sm flex-1 font-normal bg-transparent outline-none overflow-y-hidden"
placeholder="Content goes here"
/> : <MarkdownEditor
key={index}

View file

@ -45,7 +45,7 @@ export async function POST(req: NextRequest) {
}
}
return (loadedElements / totalElements) >= 0.95;
return (loadedElements / totalElements) >= 0.99;
}
`,
{ timeout: 10000 }

View file

@ -15,6 +15,7 @@ interface GetAllChildElementsAttributesArgs {
inheritedFont?: ElementAttributes['font'];
inheritedBackground?: ElementAttributes['background'];
inheritedBorderRadius?: number[];
inheritedZIndex?: number;
screenshotsDir: string;
}
@ -181,16 +182,20 @@ async function getSlidesWrapper(page: Page): Promise<ElementHandle<Element>> {
return slides_wrapper;
}
async function getAllChildElementsAttributes({ element, rootRect = null, depth = 0, inheritedFont, inheritedBackground, inheritedBorderRadius, screenshotsDir }: GetAllChildElementsAttributesArgs): Promise<SlideAttributesResult> {
const currentRootRect = rootRect || await element.evaluate((el) => {
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,
async function getAllChildElementsAttributes({ element, rootRect = null, depth = 0, inheritedFont, inheritedBackground, inheritedBorderRadius, inheritedZIndex, screenshotsDir }: GetAllChildElementsAttributesArgs): Promise<SlideAttributesResult> {
if (!rootRect) {
const rootAttributes = await getElementAttributes(element);
inheritedFont = rootAttributes.font;
inheritedBackground = rootAttributes.background;
inheritedZIndex = rootAttributes.zIndex;
rootRect = {
left: rootAttributes.position?.left ?? 0,
top: rootAttributes.position?.top ?? 0,
width: rootAttributes.position?.width ?? 1280,
height: rootAttributes.position?.height ?? 720,
};
});
}
const directChildElementHandles = await element.$$(':scope > *');
@ -199,6 +204,10 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth =
for (const childElementHandle of directChildElementHandles) {
const attributes = await getElementAttributes(childElementHandle);
if (attributes.tagName === "style") {
continue;
}
if (inheritedFont && !attributes.font && attributes.innerText && attributes.innerText.trim().length > 0) {
attributes.font = inheritedFont;
}
@ -208,11 +217,14 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth =
if (inheritedBorderRadius && !attributes.borderRadius) {
attributes.borderRadius = inheritedBorderRadius;
}
if (inheritedZIndex !== undefined && attributes.zIndex === 0) {
attributes.zIndex = inheritedZIndex;
}
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,
left: attributes.position.left - rootRect!.left,
top: attributes.position.top - rootRect!.top,
width: attributes.position.width,
height: attributes.position.height,
};
@ -227,30 +239,31 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth =
//? If the element is a svg, canvas, or table, we don't need to go deeper
if (attributes.should_screenshot) {
break;
continue;
}
const childResults = await getAllChildElementsAttributes({
element: childElementHandle,
rootRect: currentRootRect,
rootRect: rootRect,
depth: depth + 1,
inheritedFont: attributes.font || inheritedFont,
inheritedBackground: attributes.background || inheritedBackground,
inheritedBorderRadius: attributes.borderRadius || inheritedBorderRadius,
inheritedZIndex: attributes.zIndex || inheritedZIndex,
screenshotsDir,
});
allResults.push(...childResults.elements.map(attr => ({ attributes: attr, depth: depth + 1 })));
}
let backgroundColor: string | undefined;
if (!rootRect) {
let backgroundColor = inheritedBackground?.color;
if (depth === 0) {
const elementsWithRootPosition = allResults.filter(({ attributes }) => {
return attributes.position &&
attributes.position.left === 0 &&
attributes.position.top === 0 &&
attributes.position.width === currentRootRect.width &&
attributes.position.height === currentRootRect.height;
attributes.position.width === rootRect!.width &&
attributes.position.height === rootRect!.height;
});
for (const { attributes } of elementsWithRootPosition) {
@ -261,7 +274,7 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth =
}
}
const filteredResults = !rootRect ? allResults.filter(({ attributes }) => {
const filteredResults = depth === 0 ? allResults.filter(({ attributes }) => {
const hasBackground = attributes.background && attributes.background.color;
const hasBorder = attributes.border && attributes.border.color;
const hasShadow = attributes.shadow && attributes.shadow.color;
@ -274,21 +287,21 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth =
const isRootPosition = attributes.position &&
attributes.position.left === 0 &&
attributes.position.top === 0 &&
attributes.position.width === currentRootRect.width &&
attributes.position.height === currentRootRect.height;
attributes.position.width === rootRect!.width &&
attributes.position.height === rootRect!.height;
const hasOtherProperties = hasBackground || hasBorder || hasShadow || hasText || hasImage || isSvg || isCanvas || isTable;
return hasOtherProperties && !isRootPosition;
}) : allResults;
if (!rootRect) {
if (depth === 0) {
const sortedElements = filteredResults
.sort((a, b) => {
const zIndexA = a.attributes.zIndex || 0;
const zIndexB = b.attributes.zIndex || 0;
if (zIndexA === zIndexB) {
return b.depth - a.depth;
return a.depth - b.depth;
}
return zIndexB - zIndexA;
@ -404,20 +417,39 @@ async function getElementAttributes(element: ElementHandle<Element>): Promise<El
function parseBackground(computedStyles: CSSStyleDeclaration) {
const backgroundColorResult = colorToHex(computedStyles.backgroundColor);
return {
const background = {
color: backgroundColorResult.hex,
opacity: backgroundColorResult.opacity
};
// Return undefined if background has no meaningful values
if (!background.color && background.opacity === undefined) {
return undefined;
}
return background;
}
function parseBorder(computedStyles: CSSStyleDeclaration) {
const borderColorResult = colorToHex(computedStyles.borderColor);
const borderWidth = parseFloat(computedStyles.borderWidth);
return borderWidth === 0 ? undefined : {
if (borderWidth === 0) {
return undefined;
}
const border = {
color: borderColorResult.hex,
width: isNaN(borderWidth) ? undefined : borderWidth,
opacity: borderColorResult.opacity,
};
// Return undefined if border has no meaningful values
if (!border.color && border.width === undefined && border.opacity === undefined) {
return undefined;
}
return border;
}
function parseShadow(computedStyles: CSSStyleDeclaration) {
@ -590,31 +622,32 @@ async function getElementAttributes(element: ElementHandle<Element>): Promise<El
const blurRadius = numericParts.length >= 3 ? numericParts[2] : 0;
const spreadRadius = numericParts.length >= 4 ? numericParts[3] : 0;
let shadowColor = 'rgba(0, 0, 0, 0.3)'; // default color
// Only create shadow if color is present
if (colorParts.length > 0) {
shadowColor = colorParts.join(' ');
}
const shadowColor = colorParts.join(' ');
const shadowColorResult = colorToHex(shadowColor);
const shadowColorResult = colorToHex(shadowColor);
const hasValidValues = offsetX !== 0 || offsetY !== 0 || blurRadius > 0 || spreadRadius !== 0 ||
(shadowColorResult.hex && shadowColorResult.hex !== '000000' && shadowColorResult.opacity !== 0);
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),
};
if (shadowColorResult.hex) {
shadow = {
offset: [offsetX, offsetY],
color: shadowColorResult.hex,
opacity: shadowColorResult.opacity,
radius: blurRadius,
spread: spreadRadius,
inset: isInset,
angle: Math.atan2(offsetY, offsetX) * (180 / Math.PI),
};
}
}
}
}
}
// Return undefined if shadow is empty (no meaningful values)
if (Object.keys(shadow).length === 0) {
return undefined;
}
return shadow;
}
@ -631,13 +664,20 @@ async function getElementAttributes(element: ElementHandle<Element>): Promise<El
fontName = firstFont;
}
return {
const font = {
name: fontName,
size: isNaN(fontSize) ? undefined : fontSize,
weight: isNaN(fontWeight) ? undefined : fontWeight,
color: fontColorResult.hex,
italic: fontStyle === 'italic',
};
// Return undefined if font has no meaningful values
if (!font.name && font.size === undefined && font.weight === undefined && !font.color && !font.italic) {
return undefined;
}
return font;
}
function parseLineHeight(computedStyles: CSSStyleDeclaration, el: Element) {
@ -722,7 +762,7 @@ async function getElementAttributes(element: ElementHandle<Element>): Promise<El
const rect = el.getBoundingClientRect();
const maxRadiusX = rect.width / 2;
const maxRadiusY = rect.height / 2;
borderRadiusValue = borderRadiusValue.map((radius, index) => {
// For top-left and bottom-right corners, use maxRadiusX
// For top-right and bottom-left corners, use maxRadiusY
@ -779,27 +819,29 @@ async function getElementAttributes(element: ElementHandle<Element>): Promise<El
return {
tagName: el.tagName.toLowerCase(),
id: el.id || undefined,
id: el.id,
className: (el.className && typeof el.className === 'string') ? el.className : (el.className ? el.className.toString() : undefined),
innerText,
background,
border,
shadow,
font,
position,
margin,
padding,
innerText: innerText,
background: background,
border: border,
shadow: shadow,
font: font,
position: position,
margin: margin,
padding: padding,
zIndex: zIndexValue,
textAlign: textAlign !== 'left' ? textAlign : undefined,
lineHeight,
lineHeight: lineHeight,
borderRadius: borderRadiusValue,
imageSrc: imageSrc || undefined,
objectFit,
imageSrc: imageSrc,
objectFit: objectFit,
clip: false,
overlay: undefined,
shape,
shape: shape,
connectorType: undefined,
textWrap,
textWrap: textWrap,
should_screenshot: false,
element: undefined,
};
}

View file

@ -20,9 +20,6 @@ import {
PptxConnectorType
} from "@/types/pptx_models";
/**
* Converts text alignment string to PptxAlignment enum value
*/
function convertTextAlignToPptxAlignment(textAlign?: string): PptxAlignment | undefined {
if (!textAlign) return undefined;
@ -40,45 +37,33 @@ 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
calculatedLineHeight = Math.round((lineHeight / fontSize) * 100) / 100;
}
return calculatedLineHeight - 0.4 + (fontSize ?? 16) * 0.004;
return calculatedLineHeight - 0.3
}
/**
* Converts SlideAttributesResult[] to PptxSlideModel[]
* Each SlideAttributesResult represents elements on a slide
*/
export function convertElementAttributesToPptxSlides(
slidesAttributes: SlideAttributesResult[]
): PptxSlideModel[] {
return slidesAttributes.map((slideAttributes) => {
const shapes = slideAttributes.elements.map(element => {
return convertElementToPptxShape(element);
}).filter(Boolean); // Remove any null/undefined shapes
}).filter(Boolean);
const slide: PptxSlideModel = {
shapes: shapes as (PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel)[]
};
// Add background color if available
if (slideAttributes.backgroundColor) {
slide.background = {
color: slideAttributes.backgroundColor,
@ -90,39 +75,28 @@ export function convertElementAttributesToPptxSlides(
});
}
/**
* Converts a single ElementAttributes to the appropriate PPTX shape model
*/
function convertElementToPptxShape(
element: ElementAttributes
): PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel | null {
// Skip elements without position
if (!element.position) {
return null;
}
// Check if it's an image element
if (element.tagName === 'img' || (element.className && typeof element.className === 'string' && element.className.includes('image')) || element.imageSrc) {
return convertToPictureBox(element);
}
// Check if it's a text element
if (element.innerText && element.innerText.trim().length > 0) {
return convertToTextBox(element);
}
// Check if it's a connector/line element
if (element.tagName === 'hr' || (element.className && typeof element.className === 'string' && (element.className.includes('connector') || element.className.includes('line')))) {
return convertToConnector(element);
}
// Default to auto shape box for other elements
return convertToAutoShapeBox(element);
}
/**
* Converts element to PptxTextBoxModel
*/
function convertToTextBox(element: ElementAttributes): PptxTextBoxModel {
const position: PptxPositionModel = {
left: Math.round(element.position?.left ?? 0),
@ -161,9 +135,6 @@ function convertToTextBox(element: ElementAttributes): PptxTextBoxModel {
};
}
/**
* Converts element to PptxAutoShapeBoxModel
*/
function convertToAutoShapeBox(element: ElementAttributes): PptxAutoShapeBoxModel {
const position: PptxPositionModel = {
left: Math.round(element.position?.left ?? 0),
@ -178,25 +149,24 @@ function convertToAutoShapeBox(element: ElementAttributes): PptxAutoShapeBoxMode
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,
opacity: element.border.opacity ?? 1.0
} : undefined;
const shadow: PptxShadowModel | undefined = element.shadow?.color ? {
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
radius: Math.round(element.shadow.radius ?? 4),
offset: Math.round(element.shadow.offset ? Math.sqrt(element.shadow.offset[0] ** 2 + element.shadow.offset[1] ** 2) : 0),
color: element.shadow.color,
opacity: element.shadow.opacity ?? 0.5, // float - keep as number
angle: Math.round(element.shadow.angle ?? 0) // int
opacity: element.shadow.opacity ?? 0.5,
angle: Math.round(element.shadow.angle ?? 0)
} : undefined;
// Check if element has text content
const paragraphs: PptxParagraphModel[] | undefined = element.innerText ? [{
spacing: undefined,
alignment: convertTextAlignToPptxAlignment(element.textAlign),
font: element.font ? {
name: element.font.name ?? "Inter",
size: Math.round(element.font.size ?? 16), // int
size: Math.round(element.font.size ?? 16),
font_weight: element.font.weight ?? 400,
italic: element.font.italic ?? false,
color: element.font.color ?? "000000"
@ -205,22 +175,21 @@ function convertToAutoShapeBox(element: ElementAttributes): PptxAutoShapeBoxMode
text: element.innerText
}] : undefined;
const shapeType = element.borderRadius ? PptxShapeType.ROUNDED_RECTANGLE : PptxShapeType.RECTANGLE;
return {
type: PptxShapeType.ROUNDED_RECTANGLE, // Default to rounded rectangle
type: shapeType,
margin: undefined,
fill,
stroke,
shadow,
position,
text_wrap: element.textWrap ?? true,
border_radius: element.borderRadius ? Math.round(element.borderRadius[0]) : 0, // int - use first value for autoshape
border_radius: element.borderRadius ? Math.round(element.borderRadius[0]) : undefined,
paragraphs
};
}
/**
* Converts element to PptxPictureBoxModel
*/
function convertToPictureBox(element: ElementAttributes): PptxPictureBoxModel {
const position: PptxPositionModel = {
left: Math.round(element.position?.left ?? 0),
@ -233,8 +202,6 @@ function convertToPictureBox(element: ElementAttributes): PptxPictureBoxModel {
fit: element.objectFit ? (element.objectFit as PptxObjectFitEnum) : PptxObjectFitEnum.CONTAIN
};
// Extract image path from element attributes
const picture: PptxPictureModel = {
is_network: element.imageSrc ? element.imageSrc.startsWith('http') : false,
path: element.imageSrc || ''
@ -245,16 +212,13 @@ function convertToPictureBox(element: ElementAttributes): PptxPictureBoxModel {
margin: undefined,
clip: element.clip ?? true,
overlay: element.overlay,
border_radius: element.borderRadius ? element.borderRadius.map(r => Math.round(r)) : undefined, // List[int] - 4 elements from route parsing
border_radius: element.borderRadius ? element.borderRadius.map(r => Math.round(r)) : undefined,
shape: element.shape ? (element.shape as PptxBoxShapeEnum) : PptxBoxShapeEnum.RECTANGLE,
object_fit: objectFit,
picture
};
}
/**
* Converts element to PptxConnectorModel
*/
function convertToConnector(element: ElementAttributes): PptxConnectorModel {
const position: PptxPositionModel = {
left: Math.round(element.position?.left ?? 0),
@ -264,9 +228,9 @@ function convertToConnector(element: ElementAttributes): PptxConnectorModel {
};
return {
type: PptxConnectorType.STRAIGHT, // Default to straight connector
type: PptxConnectorType.STRAIGHT,
position,
thickness: element.border?.width ?? 0.5, // float - keep as number
thickness: element.border?.width ?? 0.5,
color: element.border?.color || element.background?.color || '000000',
opacity: element.border?.opacity ?? 1.0
};