Merge pull request #101 from presenton/feat/presentation_export
feat(nextjs): implements presentation export for all elements except svg, canvas and tables
This commit is contained in:
commit
523945a8f1
10 changed files with 295 additions and 22 deletions
24
package-lock.json
generated
Normal file
24
package-lock.json
generated
Normal 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
5
package.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"uuid": "^11.1.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
26
servers/nextjs/package-lock.json
generated
26
servers/nextjs/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue