diff --git a/Dockerfile b/Dockerfile index c00d2187..5bc2815b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,7 @@ RUN apt-get update && apt-get install -y \ curl \ libreoffice \ fontconfig \ + chromium \ imagemagick RUN sed -i 's/rights="none" pattern="PDF"/rights="read|write" pattern="PDF"/' /etc/ImageMagick-6/policy.xml @@ -22,7 +23,7 @@ WORKDIR /app # Set environment variables ENV APP_DATA_DIRECTORY=/app_data ENV TEMP_DIRECTORY=/tmp/presenton -# ENV PYTHONPATH="${PYTHONPATH}:/app/servers/fastapi" +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium # Install ollama @@ -39,8 +40,6 @@ WORKDIR /app/servers/nextjs COPY servers/nextjs/package.json servers/nextjs/package-lock.json ./ RUN npm install -# Install chrome for puppeteer -RUN npx puppeteer browsers install chrome@136.0.7103.92 --install-deps # Copy Next.js app COPY servers/nextjs/ /app/servers/nextjs/ diff --git a/Dockerfile.dev b/Dockerfile.dev index 6de18da2..e7bc8d2c 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -6,6 +6,7 @@ RUN apt-get update && apt-get install -y \ curl \ libreoffice \ fontconfig \ + chromium \ imagemagick RUN sed -i 's/rights="none" pattern="PDF"/rights="read|write" pattern="PDF"/' /etc/ImageMagick-6/policy.xml @@ -24,7 +25,7 @@ RUN ls -a # Set environment variables ENV APP_DATA_DIRECTORY=/app_data ENV TEMP_DIRECTORY=/tmp/presenton -# ENV PYTHONPATH="${PYTHONPATH}:/app/servers/fastapi" +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium # Install ollama RUN curl -fsSL http://ollama.com/install.sh | sh @@ -40,9 +41,6 @@ WORKDIR /node_dependencies COPY servers/nextjs/package.json servers/nextjs/package-lock.json ./ RUN npm install --verbose -# Install chrome for puppeteer -RUN npx puppeteer browsers install chrome@138.0.7204.94 --install-deps - RUN chmod -R 777 /node_dependencies # Copy nginx configuration diff --git a/servers/nextjs/app/(presentation-generator)/outline/components/LayoutSelection.tsx b/servers/nextjs/app/(presentation-generator)/outline/components/LayoutSelection.tsx index 5e0c4639..7fdc8882 100644 --- a/servers/nextjs/app/(presentation-generator)/outline/components/LayoutSelection.tsx +++ b/servers/nextjs/app/(presentation-generator)/outline/components/LayoutSelection.tsx @@ -5,200 +5,241 @@ import GroupLayouts from "./GroupLayouts"; import { LayoutGroup } from "../types/index"; interface LayoutSelectionProps { - selectedLayoutGroup: LayoutGroup | null; - onSelectLayoutGroup: (group: LayoutGroup) => void; + selectedLayoutGroup: LayoutGroup | null; + onSelectLayoutGroup: (group: LayoutGroup) => void; } const LayoutSelection: React.FC = ({ - selectedLayoutGroup, - onSelectLayoutGroup + selectedLayoutGroup, + onSelectLayoutGroup, }) => { - const { - getLayoutsByGroup, - getGroupSetting, - getAllGroups, - getFullDataByGroup, - loading - } = useLayout(); + const { + getLayoutsByGroup, + getGroupSetting, + getAllGroups, + getFullDataByGroup, + loading, + } = useLayout(); - const [summaryMap, setSummaryMap] = React.useState>({}); + const [summaryMap, setSummaryMap] = React.useState< + Record< + string, + { lastUpdatedAt?: number; name?: string; description?: string } + > + >({}); - useEffect(() => { - // Fetch custom templates summary to get last_updated_at and template meta for sorting and display - fetch("/api/v1/ppt/template-management/summary") - .then(res => res.json()) - .then(data => { - const map: Record = {}; - if (data && Array.isArray(data.presentations)) { - for (const p of data.presentations) { - const slug = `custom-${p.presentation_id}`; - map[slug] = { - lastUpdatedAt: p.last_updated_at ? new Date(p.last_updated_at).getTime() : 0, - name: p.template?.name, - description: p.template?.description, - }; - } - } - setSummaryMap(map); - }) - .catch(() => setSummaryMap({})); - }, []); - - const layoutGroups: LayoutGroup[] = React.useMemo(() => { - const groups = getAllGroups(); - if (groups.length === 0) return []; - - const Groups: LayoutGroup[] = groups - .filter(groupName => { - // Filter out groups that contain any errored layouts (from custom templates compile/parse errors) - const fullData = getFullDataByGroup(groupName); - const hasErroredLayouts = fullData.some(fd => (fd as any)?.component?.displayName === "CustomTemplateErrorSlide"); - return !hasErroredLayouts; - }) - .map(groupName => { - const settings = getGroupSetting(groupName); - const customMeta = summaryMap[groupName]; - const isCustom = groupName.toLowerCase().startsWith("custom-"); - return { - id: groupName, - name: isCustom && customMeta?.name ? customMeta.name : groupName, - description: (isCustom && customMeta?.description) ? customMeta.description : (settings?.description || `${groupName} presentation templates`), - ordered: settings?.ordered || false, - default: settings?.default || false, + useEffect(() => { + // Fetch custom templates summary to get last_updated_at and template meta for sorting and display + fetch("/api/v1/ppt/template-management/summary") + .then((res) => res.json()) + .then((data) => { + const map: Record< + string, + { lastUpdatedAt?: number; name?: string; description?: string } + > = {}; + if (data && Array.isArray(data.presentations)) { + for (const p of data.presentations) { + const slug = `custom-${p.presentation_id}`; + map[slug] = { + lastUpdatedAt: p.last_updated_at + ? new Date(p.last_updated_at).getTime() + : 0, + name: p.template?.name, + description: p.template?.description, }; - }); - - // Sort groups to put default first, then by name - return Groups.sort((a, b) => { - if (a.default && !b.default) return -1; - if (!a.default && b.default) return 1; - return a.name.localeCompare(b.name); - }); - }, [getAllGroups, getLayoutsByGroup, getGroupSetting, getFullDataByGroup, summaryMap]); - - const inBuiltGroups = React.useMemo( - () => layoutGroups.filter(g => !g.id.toLowerCase().startsWith("custom-")), - [layoutGroups] - ); - const customGroups = React.useMemo(() => { - const unsorted = layoutGroups.filter(g => g.id.toLowerCase().startsWith("custom-")); - // Sort by last_updated_at desc using summaryMap keyed by slug id - return unsorted.sort((a, b) => (summaryMap[b.id]?.lastUpdatedAt || 0) - (summaryMap[a.id]?.lastUpdatedAt || 0)); - }, [layoutGroups, summaryMap]); - - // Auto-select first group when groups are loaded - useEffect(() => { - if (layoutGroups.length > 0 && !selectedLayoutGroup) { - const defaultGroup = layoutGroups.find(g => g.default) || layoutGroups[0]; - const slides = getLayoutsByGroup(defaultGroup.id); - - onSelectLayoutGroup({ - ...defaultGroup, - slides: slides, - }); + } } - }, [layoutGroups, selectedLayoutGroup, onSelectLayoutGroup]); - useEffect(() => { + setSummaryMap(map); + }) + .catch(() => setSummaryMap({})); + }, []); + + const layoutGroups: LayoutGroup[] = React.useMemo(() => { + const groups = getAllGroups(); + + console.log("All groups: ", groups); + + if (groups.length === 0) return []; + + const Groups: LayoutGroup[] = groups + .filter((groupName) => { + // Filter out groups that contain any errored layouts (from custom templates compile/parse errors) + const fullData = getFullDataByGroup(groupName); + const hasErroredLayouts = fullData.some( + (fd) => + (fd as any)?.component?.displayName === "CustomTemplateErrorSlide" + ); + return !hasErroredLayouts; + }) + .map((groupName) => { + const settings = getGroupSetting(groupName); + const customMeta = summaryMap[groupName]; + const isCustom = groupName.toLowerCase().startsWith("custom-"); + return { + id: groupName, + name: isCustom && customMeta?.name ? customMeta.name : groupName, + description: + isCustom && customMeta?.description + ? customMeta.description + : settings?.description || `${groupName} presentation templates`, + ordered: settings?.ordered || false, + default: settings?.default || false, + }; + }); + + // Sort groups to put default first, then by name + return Groups.sort((a, b) => { + if (a.default && !b.default) return -1; + if (!a.default && b.default) return 1; + return a.name.localeCompare(b.name); + }); + }, [ + getAllGroups, + getLayoutsByGroup, + getGroupSetting, + getFullDataByGroup, + summaryMap, + ]); + + const inBuiltGroups = React.useMemo( + () => layoutGroups.filter((g) => !g.id.toLowerCase().startsWith("custom-")), + [layoutGroups] + ); + const customGroups = React.useMemo(() => { + const unsorted = layoutGroups.filter((g) => + g.id.toLowerCase().startsWith("custom-") + ); + // Sort by last_updated_at desc using summaryMap keyed by slug id + return unsorted.sort( + (a, b) => + (summaryMap[b.id]?.lastUpdatedAt || 0) - + (summaryMap[a.id]?.lastUpdatedAt || 0) + ); + }, [layoutGroups, summaryMap]); + + // Auto-select first group when groups are loaded + useEffect(() => { + if (layoutGroups.length > 0 && !selectedLayoutGroup) { + const defaultGroup = + layoutGroups.find((g) => g.default) || layoutGroups[0]; + const slides = getLayoutsByGroup(defaultGroup.id); + + onSelectLayoutGroup({ + ...defaultGroup, + slides: slides, + }); + } + }, [layoutGroups, selectedLayoutGroup, onSelectLayoutGroup]); + useEffect(() => { if (loading) { return; } - const existingScript = document.querySelector( - 'script[src*="tailwindcss.com"]' - ); - if (!existingScript) { - const script = document.createElement("script"); - script.src = "https://cdn.tailwindcss.com"; - script.async = true; - document.head.appendChild(script); - } - - }, []); - - - if (loading) { - return ( -
-
- {[1, 2, 3, 4].map((i) => ( -
-
-
-
- {[1, 2, 3].map((j) => ( -
- ))} -
-
- ))} -
-
- ); - } - - if (layoutGroups.length === 0) { - return ( -
-
-
- No Templates Available -
-

- No presentation templates could be loaded. Please try refreshing the page. -

-
-
- ); - } - - const handleLayoutGroupSelection = (group: LayoutGroup) => { - const slides = getLayoutsByGroup(group.id); - onSelectLayoutGroup({ - ...group, - slides: slides, - }); - } - - return ( -
- {/* In Built Templates */} -
-

In Built Templates

-
- {inBuiltGroups.map((group) => ( - - ))} -
-
- - {/* Custom AI Templates */} -
-
-

Custom AI Templates

-
- {customGroups.length === 0 ? ( -
- No custom templates. Create one from "Create Template" menu. -
- ) : ( -
- {customGroups.map((group) => ( - - ))} -
- )} -
-
+ const existingScript = document.querySelector( + 'script[src*="tailwindcss.com"]' ); + if (!existingScript) { + const script = document.createElement("script"); + script.src = "https://cdn.tailwindcss.com"; + script.async = true; + document.head.appendChild(script); + } + }, []); + + if (loading) { + return ( +
+
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+ {[1, 2, 3].map((j) => ( +
+ ))} +
+
+ ))} +
+
+ ); + } + + if (layoutGroups.length === 0) { + return ( +
+
+
+ No Templates Available +
+

+ No presentation templates could be loaded. Please try refreshing the + page. +

+
+
+ ); + } + + const handleLayoutGroupSelection = (group: LayoutGroup) => { + const slides = getLayoutsByGroup(group.id); + onSelectLayoutGroup({ + ...group, + slides: slides, + }); + }; + + return ( +
+ {/* In Built Templates */} +
+

+ In Built Templates +

+
+ {inBuiltGroups.map((group) => ( + + ))} +
+
+ + {/* Custom AI Templates */} +
+
+

+ Custom AI Templates +

+
+ {customGroups.length === 0 ? ( +
+ No custom templates. Create one from "Create Template" menu. +
+ ) : ( +
+ {customGroups.map((group) => ( + + ))} +
+ )} +
+
+ ); }; -export default LayoutSelection; \ No newline at end of file +export default LayoutSelection; diff --git a/servers/nextjs/app/api/export-as-pdf/route.ts b/servers/nextjs/app/api/export-as-pdf/route.ts index 836f633d..99111a4b 100644 --- a/servers/nextjs/app/api/export-as-pdf/route.ts +++ b/servers/nextjs/app/api/export-as-pdf/route.ts @@ -1,31 +1,45 @@ -import path from 'path'; -import fs from 'fs'; -import puppeteer from 'puppeteer'; - -import { sanitizeFilename } from '@/app/(presentation-generator)/utils/others'; -import { NextResponse, NextRequest } from 'next/server'; +import path from "path"; +import fs from "fs"; +import puppeteer from "puppeteer"; +import { sanitizeFilename } from "@/app/(presentation-generator)/utils/others"; +import { NextResponse, NextRequest } from "next/server"; export async function POST(req: NextRequest) { const { id, title } = await req.json(); if (!id) { - return NextResponse.json({ error: "Missing Presentation ID" }, { status: 400 }); + return NextResponse.json( + { error: "Missing Presentation ID" }, + { status: 400 } + ); } const browser = await puppeteer.launch({ + executablePath: process.env.PUPPETEER_EXECUTABLE_PATH, headless: true, args: [ - '--no-sandbox', - '--disable-web-security', - ] + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--disable-web-security", + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + "--disable-features=TranslateUI", + "--disable-ipc-flooding-protection", + ], }); const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 720 }); page.setDefaultNavigationTimeout(300000); page.setDefaultTimeout(300000); - await page.goto(`http://localhost/pdf-maker?id=${id}`, { waitUntil: 'networkidle0', timeout: 300000 }); + await page.goto(`http://localhost/pdf-maker?id=${id}`, { + waitUntil: "networkidle0", + timeout: 300000, + }); - await page.waitForFunction('() => document.readyState === "complete"') + await page.waitForFunction('() => document.readyState === "complete"'); try { await page.waitForFunction( @@ -52,13 +66,11 @@ export async function POST(req: NextRequest) { { timeout: 300000 } ); - await new Promise(resolve => setTimeout(resolve, 1000)); - + await new Promise((resolve) => setTimeout(resolve, 1000)); } catch (error) { console.log("Warning: Some content may not have loaded completely:", error); } - const pdfBuffer = await page.pdf({ width: "1280px", height: "720px", @@ -68,13 +80,17 @@ export async function POST(req: NextRequest) { browser.close(); - const sanitizedTitle = sanitizeFilename(title ?? 'presentation'); - const destinationPath = path.join(process.env.APP_DATA_DIRECTORY!, 'exports', `${sanitizedTitle}.pdf`); + const sanitizedTitle = sanitizeFilename(title ?? "presentation"); + const destinationPath = path.join( + process.env.APP_DATA_DIRECTORY!, + "exports", + `${sanitizedTitle}.pdf` + ); await fs.promises.mkdir(path.dirname(destinationPath), { recursive: true }); await fs.promises.writeFile(destinationPath, pdfBuffer); return NextResponse.json({ success: true, - path: destinationPath + path: destinationPath, }); } diff --git a/servers/nextjs/app/api/layout/route.ts b/servers/nextjs/app/api/layout/route.ts deleted file mode 100644 index 863fab44..00000000 --- a/servers/nextjs/app/api/layout/route.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { NextResponse } from "next/server"; -import puppeteer from "puppeteer"; - -export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const groupName = searchParams.get("group"); - console.log("API called with group:", groupName); - - if (!groupName) { - console.warn("No group name provided in query params"); - return NextResponse.json({ error: "Missing group name" }, { status: 400 }); - } - - const schemaPageUrl = `http://localhost/schema?group=${encodeURIComponent(groupName)}`; - console.log("Fetching client page:", schemaPageUrl); - - let browser; - try { - browser = await puppeteer.launch({ - headless: true, - args: ["--no-sandbox", "--disable-web-security"], - }); - const page = await browser.newPage(); - await page.setViewport({ width: 1280, height: 720 }); - page.setDefaultNavigationTimeout(300000); - page.setDefaultTimeout(300000); - await page.goto(schemaPageUrl, { - waitUntil: "networkidle0", - timeout: 300000, - }); - - await page.waitForSelector("[data-layouts]", { timeout: 300000 }); - - // Extract both data-layouts and data-group-settings attributes - const { dataLayouts, dataGroupSettings } = await page.$eval( - "[data-layouts]", - (el) => ({ - dataLayouts: el.getAttribute("data-layouts"), - dataGroupSettings: el.getAttribute("data-group-settings"), - }), - ); - - let slides, groupSettings; - try { - slides = JSON.parse(dataLayouts || "[]"); - } catch (e) { - console.error("Failed to parse data-layouts JSON:", e); - slides = []; - } - try { - groupSettings = JSON.parse(dataGroupSettings || "null"); - } catch (e) { - console.error("Failed to parse data-group-settings JSON:", e); - groupSettings = null; - } - - // Compose the response to match PresentationLayoutModel - const response = { - name: groupName, - ordered: groupSettings?.ordered ?? false, - slides: slides.map((slide: any) => ({ - id: slide.id, - name: slide.name, - description: slide.description, - json_schema: slide.json_schema, - })), - }; - - return NextResponse.json(response); - } catch (err) { - console.error("Error fetching or parsing client page:", err); - return NextResponse.json( - { error: "Failed to fetch or parse client page" }, - { status: 500 }, - ); - } finally { - if (browser) await browser.close(); - } -} diff --git a/servers/nextjs/app/api/layouts/route.ts b/servers/nextjs/app/api/layouts/route.ts deleted file mode 100644 index 2a08b4d6..00000000 --- a/servers/nextjs/app/api/layouts/route.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { NextResponse } from 'next/server' -import { promises as fs } from 'fs' -import path from 'path' -import { GroupSetting } from '@/app/(presentation-generator)/template-preview/types' - -export async function GET() { - try { - // Get the path to the presentation-templates directory - const layoutsDirectory = path.join(process.cwd(), 'presentation-templates') - - // Read all directories in the presentation-templates directory - const items = await fs.readdir(layoutsDirectory, { withFileTypes: true }) - - // Filter for directories (layout groups) and exclude files - const groupDirectories = items - .filter(item => item.isDirectory()) - .map(dir => dir.name) - - const allLayouts: { groupName: string; files: string[]; settings: GroupSetting | null }[] = [] - - // Scan each group directory for layout files and settings - for (const groupName of groupDirectories) { - try { - const groupPath = path.join(layoutsDirectory, groupName) - const groupFiles = await fs.readdir(groupPath) - - // Filter for .tsx files and exclude any non-layout files - const layoutFiles = groupFiles.filter(file => - file.endsWith('.tsx') && - !file.startsWith('.') && - !file.includes('.test.') && - !file.includes('.spec.') && - file !== 'settings.json' - ) - - // Read settings.json if it exists - let settings: GroupSetting | null = null - const settingsPath = path.join(groupPath, 'settings.json') - try { - const settingsContent = await fs.readFile(settingsPath, 'utf-8') - settings = JSON.parse(settingsContent) as GroupSetting - } catch (settingsError) { - - console.warn(`No settings.json found for group ${groupName} or invalid JSON`) - // Provide default settings if settings.json is missing or invalid - settings = { - description: `${groupName} presentation layouts`, - ordered: false, - default: false - } - - } - - if (layoutFiles.length > 0) { - allLayouts.push({ - groupName: groupName, - files: layoutFiles, - settings: settings - }) - } - } catch (error) { - console.error(`Error reading group directory ${groupName}:`, error) - // Continue with other groups even if one fails - } - } - - - return NextResponse.json(allLayouts) - } catch (error) { - console.error('Error reading presentation-templates directory:', error) - return NextResponse.json( - { error: 'Failed to read presentation-templates directory' }, - { status: 500 } - ) - } -} \ No newline at end of file diff --git a/servers/nextjs/app/api/presentation_to_pptx_model/route.ts b/servers/nextjs/app/api/presentation_to_pptx_model/route.ts index 0cebd027..b1ff1843 100644 --- a/servers/nextjs/app/api/presentation_to_pptx_model/route.ts +++ b/servers/nextjs/app/api/presentation_to_pptx_model/route.ts @@ -1,27 +1,34 @@ import { ApiError } from "@/models/errors"; import { NextRequest, NextResponse } from "next/server"; import puppeteer, { Browser, ElementHandle, Page } from "puppeteer"; -import { ElementAttributes, SlideAttributesResult } from "@/types/element_attibutes"; +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 { v4 as uuidv4 } from 'uuid'; +import { v4 as uuidv4 } from "uuid"; import sharp from "sharp"; interface GetAllChildElementsAttributesArgs { element: ElementHandle; - rootRect?: { left: number; top: number; width: number; height: number } | null; + rootRect?: { + left: number; + top: number; + width: number; + height: number; + } | null; depth?: number; - inheritedFont?: ElementAttributes['font']; - inheritedBackground?: ElementAttributes['background']; + inheritedFont?: ElementAttributes["font"]; + inheritedBackground?: ElementAttributes["background"]; inheritedBorderRadius?: number[]; inheritedZIndex?: number; inheritedOpacity?: number; screenshotsDir: string; } - export async function GET(request: NextRequest) { let browser: Browser | null = null; let page: Page | null = null; @@ -33,8 +40,13 @@ export async function GET(request: NextRequest) { const { slides, speakerNotes } = await getSlidesAndSpeakerNotes(page); const slides_attributes = await getSlidesAttributes(slides, screenshotsDir); - await postProcessSlidesAttributes(slides_attributes, screenshotsDir, speakerNotes); - const slides_pptx_models = convertElementAttributesToPptxSlides(slides_attributes); + await postProcessSlidesAttributes( + slides_attributes, + screenshotsDir, + speakerNotes + ); + const slides_pptx_models = + convertElementAttributesToPptxSlides(slides_attributes); const presentation_pptx_model: PptxPresentationModel = { slides: slides_pptx_models, }; @@ -48,7 +60,10 @@ export async function GET(request: NextRequest) { if (error instanceof ApiError) { return NextResponse.json(error, { status: 400 }); } - return NextResponse.json({ detail: `Internal server error: ${error.message}` }, { status: 500 }); + return NextResponse.json( + { detail: `Internal server error: ${error.message}` }, + { status: 500 } + ); } } @@ -62,13 +77,19 @@ async function getPresentationId(request: NextRequest) { async function getBrowserAndPage(id: string): Promise<[Browser, Page]> { const browser = await puppeteer.launch({ + executablePath: process.env.PUPPETEER_EXECUTABLE_PATH, headless: true, args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - '--disable-gpu', - '--disable-web-security', + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--disable-web-security", + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + "--disable-features=TranslateUI", + "--disable-ipc-flooding-protection", ], }); @@ -92,24 +113,30 @@ async function closeBrowserAndPage(browser: Browser | null, page: Page | null) { function getScreenshotsDir() { const tempDir = process.env.TEMP_DIRECTORY; if (!tempDir) { - console.warn('TEMP_DIRECTORY environment variable not set, skipping screenshot'); - throw new ApiError('TEMP_DIRECTORY environment variable not set'); + console.warn( + "TEMP_DIRECTORY environment variable not set, skipping screenshot" + ); + throw new ApiError("TEMP_DIRECTORY environment variable not set"); } - const screenshotsDir = path.join(tempDir, 'screenshots'); + const screenshotsDir = path.join(tempDir, "screenshots"); if (!fs.existsSync(screenshotsDir)) { fs.mkdirSync(screenshotsDir, { recursive: true }); } return screenshotsDir; } -async function postProcessSlidesAttributes(slidesAttributes: SlideAttributesResult[], screenshotsDir: string, speakerNotes: string[]) { +async function postProcessSlidesAttributes( + slidesAttributes: SlideAttributesResult[], + screenshotsDir: string, + speakerNotes: string[] +) { for (const [index, slideAttributes] of slidesAttributes.entries()) { for (const element of slideAttributes.elements) { if (element.should_screenshot) { const screenshotPath = await screenshotElement(element, screenshotsDir); element.imageSrc = screenshotPath; element.should_screenshot = false; - element.objectFit = 'cover'; + element.objectFit = "cover"; element.element = undefined; } } @@ -117,46 +144,62 @@ async function postProcessSlidesAttributes(slidesAttributes: SlideAttributesResu } } -async function screenshotElement(element: ElementAttributes, screenshotsDir: string) { - const screenshotPath = path.join(screenshotsDir, `${uuidv4()}.png`) as `${string}.png`; +async function screenshotElement( + element: ElementAttributes, + screenshotsDir: string +) { + const screenshotPath = path.join( + screenshotsDir, + `${uuidv4()}.png` + ) as `${string}.png`; // For SVG elements, use convertSvgToPng - if (element.tagName === 'svg') { + if (element.tagName === "svg") { const pngBuffer = await convertSvgToPng(element); fs.writeFileSync(screenshotPath, pngBuffer); return screenshotPath; } // Hide all elements except the target element and its ancestors - await element.element?.evaluate((el) => { - const originalOpacities = new Map(); + await element.element?.evaluate( + (el) => { + const originalOpacities = new Map(); - const hideAllExcept = (targetElement: Element) => { - const allElements = document.querySelectorAll('*'); + const hideAllExcept = (targetElement: Element) => { + const allElements = document.querySelectorAll("*"); - allElements.forEach((elem) => { - const computedStyle = window.getComputedStyle(elem); - originalOpacities.set(elem, computedStyle.opacity); + allElements.forEach((elem) => { + const computedStyle = window.getComputedStyle(elem); + originalOpacities.set(elem, computedStyle.opacity); - if (targetElement === elem || targetElement.contains(elem) || elem.contains(targetElement)) { - (elem as HTMLElement).style.opacity = computedStyle.opacity || "1"; - return; - } + if ( + targetElement === elem || + targetElement.contains(elem) || + elem.contains(targetElement) + ) { + (elem as HTMLElement).style.opacity = computedStyle.opacity || "1"; + return; + } - (elem as HTMLElement).style.opacity = '0'; - }); - }; + (elem as HTMLElement).style.opacity = "0"; + }); + }; - hideAllExcept(el); + hideAllExcept(el); - (el as any).__restoreStyles = () => { - originalOpacities.forEach((opacity, elem) => { - (elem as HTMLElement).style.opacity = opacity; - }); - }; - }, element.opacity, element.font?.color); + (el as any).__restoreStyles = () => { + originalOpacities.forEach((opacity, elem) => { + (elem as HTMLElement).style.opacity = opacity; + }); + }; + }, + element.opacity, + element.font?.color + ); - const screenshot = await element.element?.screenshot({ path: screenshotPath }); + const screenshot = await element.element?.screenshot({ + path: screenshotPath, + }); if (!screenshot) { throw new ApiError("Failed to screenshot element"); } @@ -171,32 +214,38 @@ async function screenshotElement(element: ElementAttributes, screenshotsDir: str } const convertSvgToPng = async (element_attibutes: ElementAttributes) => { - const svgHtml = await element_attibutes.element?.evaluate((el) => { + const svgHtml = + (await element_attibutes.element?.evaluate((el) => { + // Apply font color + const fontColor = window.getComputedStyle(el).color; + (el as HTMLElement).style.color = fontColor; - // Apply font color - const fontColor = window.getComputedStyle(el).color; - (el as HTMLElement).style.color = fontColor; - - return el.outerHTML; - }) || ''; + return el.outerHTML; + })) || ""; const svgBuffer = Buffer.from(svgHtml); const pngBuffer = await sharp(svgBuffer) - .resize(Math.round(element_attibutes.position!.width!), Math.round(element_attibutes.position!.height!)) - .toFormat('png') + .resize( + Math.round(element_attibutes.position!.width!), + Math.round(element_attibutes.position!.height!) + ) + .toFormat("png") .toBuffer(); return pngBuffer; -} +}; - -async function getSlidesAttributes(slides: ElementHandle[], screenshotsDir: string): Promise { +async function getSlidesAttributes( + slides: ElementHandle[], + screenshotsDir: string +): Promise { const slideAttributes = await Promise.all( - slides.map((slide) => getAllChildElementsAttributes({ element: slide, screenshotsDir })) + slides.map((slide) => + getAllChildElementsAttributes({ element: slide, screenshotsDir }) + ) ); return slideAttributes; } - async function getSlidesAndSpeakerNotes(page: Page) { const slides_wrapper = await getSlidesWrapper(page); const speakerNotes = await getSpeakerNotes(slides_wrapper); @@ -214,11 +263,23 @@ async function getSlidesWrapper(page: Page): Promise> { async function getSpeakerNotes(slides_wrapper: ElementHandle) { return await slides_wrapper.evaluate((el) => { - return Array.from(el.querySelectorAll('[data-speaker-note]')).map((el) => el.getAttribute('data-speaker-note') || ""); + return Array.from(el.querySelectorAll("[data-speaker-note]")).map( + (el) => el.getAttribute("data-speaker-note") || "" + ); }); } -async function getAllChildElementsAttributes({ element, rootRect = null, depth = 0, inheritedFont, inheritedBackground, inheritedBorderRadius, inheritedZIndex, inheritedOpacity, screenshotsDir }: GetAllChildElementsAttributesArgs): Promise { +async function getAllChildElementsAttributes({ + element, + rootRect = null, + depth = 0, + inheritedFont, + inheritedBackground, + inheritedBorderRadius, + inheritedZIndex, + inheritedOpacity, + screenshotsDir, +}: GetAllChildElementsAttributesArgs): Promise { if (!rootRect) { const rootAttributes = await getElementAttributes(element); inheritedFont = rootAttributes.font; @@ -233,19 +294,25 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth = }; } - - const directChildElementHandles = await element.$$(':scope > *'); + const directChildElementHandles = await element.$$(":scope > *"); const allResults: { attributes: ElementAttributes; depth: number }[] = []; for (const childElementHandle of directChildElementHandles) { const attributes = await getElementAttributes(childElementHandle); - if (['style', 'script', 'link', 'meta', 'path'].includes(attributes.tagName)) { + if ( + ["style", "script", "link", "meta", "path"].includes(attributes.tagName) + ) { continue; } - if (inheritedFont && !attributes.font && attributes.innerText && attributes.innerText.trim().length > 0) { + if ( + inheritedFont && + !attributes.font && + attributes.innerText && + attributes.innerText.trim().length > 0 + ) { attributes.font = inheritedFont; } if (inheritedBackground && !attributes.background && attributes.shadow) { @@ -257,11 +324,18 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth = if (inheritedZIndex !== undefined && attributes.zIndex === 0) { attributes.zIndex = inheritedZIndex; } - if (inheritedOpacity !== undefined && (attributes.opacity === undefined || attributes.opacity === 1)) { + if ( + inheritedOpacity !== undefined && + (attributes.opacity === undefined || attributes.opacity === 1) + ) { attributes.opacity = inheritedOpacity; } - if (attributes.position && attributes.position.left !== undefined && attributes.position.top !== undefined) { + if ( + attributes.position && + attributes.position.left !== undefined && + attributes.position.top !== undefined + ) { attributes.position = { left: attributes.position.left - rootRect!.left, top: attributes.position.top - rootRect!.top, @@ -271,18 +345,28 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth = } // Ignore elements with no size (width or height) - if (attributes.position === undefined || attributes.position.width === undefined || attributes.position.height === undefined || attributes.position.width === 0 || attributes.position.height === 0) { + if ( + attributes.position === undefined || + attributes.position.width === undefined || + attributes.position.height === undefined || + attributes.position.width === 0 || + attributes.position.height === 0 + ) { continue; } // If element is paragraph and contains only inline formatting tags, don't go deeper - if (attributes.tagName === 'p') { + if (attributes.tagName === "p") { const innerElementTagNames = await childElementHandle.evaluate((el) => { - return Array.from(el.querySelectorAll('*')).map((e) => e.tagName.toLowerCase()); + return Array.from(el.querySelectorAll("*")).map((e) => + e.tagName.toLowerCase() + ); }); - const allowedInlineTags = new Set(['strong', 'u', 'em', 'code', 's']); - const hasOnlyAllowedInlineTags = innerElementTagNames.every((tag) => allowedInlineTags.has(tag)); + const allowedInlineTags = new Set(["strong", "u", "em", "code", "s"]); + const hasOnlyAllowedInlineTags = innerElementTagNames.every((tag) => + allowedInlineTags.has(tag) + ); if (innerElementTagNames.length > 0 && hasOnlyAllowedInlineTags) { attributes.innerText = await childElementHandle.evaluate((el) => { @@ -293,7 +377,11 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth = } } - if (attributes.tagName === 'svg' || attributes.tagName === 'canvas' || attributes.tagName === 'table') { + if ( + attributes.tagName === "svg" || + attributes.tagName === "canvas" || + attributes.tagName === "table" + ) { attributes.should_screenshot = true; attributes.element = childElementHandle; } @@ -301,7 +389,7 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth = allResults.push({ attributes, depth }); // If the element is a canvas, or table, we don't need to go deeper - if (attributes.should_screenshot && attributes.tagName !== 'svg') { + if (attributes.should_screenshot && attributes.tagName !== "svg") { continue; } @@ -316,17 +404,24 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth = inheritedOpacity: attributes.opacity || inheritedOpacity, screenshotsDir, }); - allResults.push(...childResults.elements.map(attr => ({ attributes: attr, depth: depth + 1 }))); + allResults.push( + ...childResults.elements.map((attr) => ({ + attributes: attr, + depth: depth + 1, + })) + ); } let backgroundColor = inheritedBackground?.color; if (depth === 0) { const elementsWithRootPosition = allResults.filter(({ attributes }) => { - return attributes.position && + return ( + attributes.position && attributes.position.left === 0 && attributes.position.top === 0 && attributes.position.width === rootRect!.width && - attributes.position.height === rootRect!.height; + attributes.position.height === rootRect!.height + ); }); for (const { attributes } of elementsWithRootPosition) { @@ -337,27 +432,34 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth = } } - 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; - const hasText = attributes.innerText && attributes.innerText.trim().length > 0; - const hasImage = attributes.imageSrc; - const isSvg = attributes.tagName === 'svg'; - const isCanvas = attributes.tagName === 'canvas'; - const isTable = attributes.tagName === 'table'; + 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; + const hasText = + attributes.innerText && attributes.innerText.trim().length > 0; + const hasImage = attributes.imageSrc; + const isSvg = attributes.tagName === "svg"; + const isCanvas = attributes.tagName === "canvas"; + const isTable = attributes.tagName === "table"; - const occupiesRoot = attributes.position && - attributes.position.left === 0 && - attributes.position.top === 0 && - attributes.position.width === rootRect!.width && - attributes.position.height === rootRect!.height; + const occupiesRoot = + attributes.position && + attributes.position.left === 0 && + attributes.position.top === 0 && + attributes.position.width === rootRect!.width && + attributes.position.height === rootRect!.height; - const hasVisualProperties = hasBackground || hasBorder || hasShadow || hasText; - const hasSpecialContent = hasImage || isSvg || isCanvas || isTable; + const hasVisualProperties = + hasBackground || hasBorder || hasShadow || hasText; + const hasSpecialContent = hasImage || isSvg || isCanvas || isTable; - return (hasVisualProperties && !occupiesRoot) || hasSpecialContent; - }) : allResults; + return (hasVisualProperties && !occupiesRoot) || hasSpecialContent; + }) + : allResults; if (depth === 0) { const sortedElements = filteredResults @@ -372,10 +474,15 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth = return zIndexB - zIndexA; }) .map(({ attributes }) => { - if (attributes.shadow && attributes.shadow.color && (!attributes.background || !attributes.background.color) && backgroundColor) { + if ( + attributes.shadow && + attributes.shadow.color && + (!attributes.background || !attributes.background.color) && + backgroundColor + ) { attributes.background = { color: backgroundColor, - opacity: undefined + opacity: undefined, }; } return attributes; @@ -383,44 +490,57 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth = return { elements: sortedElements, - backgroundColor + backgroundColor, }; } else { return { elements: filteredResults.map(({ attributes }) => attributes), - backgroundColor + backgroundColor, }; } } - -async function getElementAttributes(element: ElementHandle): Promise { - +async function getElementAttributes( + element: ElementHandle +): Promise { const attributes = await element.evaluate((el: Element) => { - - function colorToHex(color: string): { hex: string | undefined; opacity: number | undefined } { - if (!color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)') { + function colorToHex(color: string): { + hex: string | undefined; + opacity: number | undefined; + } { + if (!color || color === "transparent" || color === "rgba(0, 0, 0, 0)") { return { hex: undefined, opacity: undefined }; } - if (color.startsWith('rgba(') || color.startsWith('hsla(')) { + 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()); + 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 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'); + 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 }; + const hex = hexColor.startsWith("#") + ? hexColor.substring(1) + : hexColor; + const result = { + hex, + opacity: isNaN(opacity) ? undefined : opacity, + }; return result; } @@ -428,29 +548,31 @@ async function getElementAttributes(element: ElementHandle): Promise): Promise): Promise): Promise): Promise): Promise): Promise): Promise): Promise 0) { - const shadowColor = colorParts.join(' '); + const shadowColor = colorParts.join(" "); const colorResult = colorToHex(shadowColor); - hasVisibleColor = !!(colorResult.hex && colorResult.hex !== '000000' && colorResult.opacity !== 0); + hasVisibleColor = !!( + colorResult.hex && + colorResult.hex !== "000000" && + colorResult.opacity !== 0 + ); } - const hasNonZeroValues = numericParts.some(value => value !== 0); + const hasNonZeroValues = numericParts.some((value) => value !== 0); let shadowScore = 0; if (hasNonZeroValues) { - shadowScore += numericParts.filter(value => value !== 0).length; + shadowScore += numericParts.filter((value) => value !== 0).length; } if (hasVisibleColor) { shadowScore += 2; } - if ((hasNonZeroValues || hasVisibleColor) && shadowScore > bestShadowScore) { + if ( + (hasNonZeroValues || hasVisibleColor) && + shadowScore > bestShadowScore + ) { selectedShadow = shadowStr; bestShadowScore = shadowScore; } @@ -652,20 +780,19 @@ async function getElementAttributes(element: ElementHandle): Promise): Promise): Promise 0) { - const shadowColor = colorParts.join(' '); + const shadowColor = colorParts.join(" "); const shadowColorResult = colorToHex(shadowColor); if (shadowColorResult.hex) { @@ -741,8 +868,8 @@ async function getElementAttributes(element: ElementHandle): Promise): Promise): Promise singleLineHeight * 2; const hasOverflow = htmlEl.scrollHeight > htmlEl.clientHeight; - const isMultiline = hasExplicitLineBreaks || hasTextWrapping || hasOverflow; + const isMultiline = + hasExplicitLineBreaks || hasTextWrapping || hasOverflow; - if (isMultiline && lineHeight && lineHeight !== 'normal') { + if (isMultiline && lineHeight && lineHeight !== "normal") { const parsedLineHeight = parseFloat(lineHeight); if (!isNaN(parsedLineHeight)) { return parsedLineHeight; @@ -801,7 +940,10 @@ async function getElementAttributes(element: ElementHandle): Promise): Promise parseFloat(part)); + 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]]; + borderRadiusValue = [ + radiusParts[0], + radiusParts[0], + radiusParts[0], + radiusParts[0], + ]; } else if (radiusParts.length === 2) { - borderRadiusValue = [radiusParts[0], radiusParts[1], radiusParts[0], radiusParts[1]]; + borderRadiusValue = [ + radiusParts[0], + radiusParts[1], + radiusParts[0], + radiusParts[1], + ]; } else if (radiusParts.length === 3) { - borderRadiusValue = [radiusParts[0], radiusParts[1], radiusParts[2], radiusParts[1]]; + borderRadiusValue = [ + radiusParts[0], + radiusParts[1], + radiusParts[2], + radiusParts[1], + ]; } else if (radiusParts.length === 4) { borderRadiusValue = radiusParts; } @@ -848,7 +1013,8 @@ async function getElementAttributes(element: ElementHandle): Promise { // For top-left and bottom-right corners, use maxRadiusX // For top-right and bottom-left corners, use maxRadiusY - const maxRadius = (index === 0 || index === 2) ? maxRadiusX : maxRadiusY; + const maxRadius = + index === 0 || index === 2 ? maxRadiusX : maxRadiusY; return Math.max(0, Math.min(radius, maxRadius)); }); } @@ -858,16 +1024,19 @@ async function getElementAttributes(element: ElementHandle): Promise radius === 50) ? 'circle' : 'rectangle'; + if (el.tagName.toLowerCase() === "img") { + return borderRadiusValue && + borderRadiusValue.length === 4 && + borderRadiusValue.every((radius: number) => radius === 50) + ? "circle" + : "rectangle"; } return undefined; } function parseFilters(computedStyles: CSSStyleDeclaration) { const filter = computedStyles.filter; - if (!filter || filter === 'none') { + if (!filter || filter === "none") { return undefined; } @@ -886,7 +1055,7 @@ async function getElementAttributes(element: ElementHandle): Promise { + filterFunctions.forEach((func) => { const match = func.match(/([a-zA-Z]+)\(([^)]*)\)/); if (match) { const filterType = match[1]; @@ -894,31 +1063,31 @@ async function getElementAttributes(element: ElementHandle): Promise): Promise): Promise): Promise