From 11904c6cb063d332ff5d7a5a8564b8dd7036f0eb Mon Sep 17 00:00:00 2001 From: sauravniraula Date: Fri, 24 Apr 2026 10:12:23 +0545 Subject: [PATCH] refactor: cleans old unused export files from both docker and electron and uses package for export --- Dockerfile | 6 +- electron/app/ipc/export_handlers.ts | 4 +- electron/app/ipc/index.ts | 20 +- .../presentation_to_pptx_model_handlers.ts | 1935 ----------------- electron/app/preloads/index.ts | 24 +- .../api/v1/ppt/endpoints/presentation.py | 55 +- .../servers/fastapi/models/pptx_models.py | 198 -- .../fastapi/services/export_task_service.py | 106 +- .../services/html_to_text_runs_service.py | 65 - .../services/pptx_presentation_creator.py | 632 ------ .../fastapi/tests/test_pptx_creator.py | 40 - .../servers/fastapi/utils/export_utils.py | 90 +- electron/servers/fastapi/utils/image_utils.py | 258 --- .../components/PresentationHeader.tsx | 122 +- .../services/api/presentation-generation.ts | 25 +- .../nextjs/app/api/export-as-pdf/route.ts | 114 - .../api/presentation_to_pptx_model/route.ts | 1217 ----------- .../servers/nextjs/types/element_attibutes.ts | 82 - electron/servers/nextjs/types/global.d.ts | 6 +- electron/servers/nextjs/types/pptx_models.ts | 364 ---- electron/servers/nextjs/utils/mixpanel.ts | 1 - .../servers/nextjs/utils/pptx_models_utils.ts | 255 --- package.json | 2 +- .../api/v1/ppt/endpoints/presentation.py | 55 +- servers/fastapi/models/pptx_models.py | 198 -- .../presenton_backend.egg-info/PKG-INFO | 1 - .../presenton_backend.egg-info/SOURCES.txt | 6 +- .../presenton_backend.egg-info/requires.txt | 1 - servers/fastapi/pyproject.toml | 1 - .../fastapi/services/export_task_service.py | 106 +- .../services/html_to_text_runs_service.py | 65 - .../services/pptx_presentation_creator.py | 632 ------ servers/fastapi/tests/test_pptx_creator.py | 40 - servers/fastapi/utils/export_utils.py | 90 +- servers/fastapi/utils/image_utils.py | 258 --- servers/fastapi/uv.lock | 71 +- .../components/PresentationHeader.tsx | 43 +- .../services/api/presentation-generation.ts | 25 +- .../route.ts | 27 +- .../api/presentation_to_pptx_model/route.ts | 1228 ----------- servers/nextjs/app/api/template/route.ts | 160 +- servers/nextjs/lib/compile-template-schema.ts | 402 ++++ ....ts => run-bundled-presentation-export.ts} | 13 +- servers/nextjs/package-lock.json | 794 +------ servers/nextjs/package.json | 4 +- servers/nextjs/types/element_attibutes.ts | 82 - servers/nextjs/types/pptx_models.ts | 364 ---- servers/nextjs/utils/mixpanel.ts | 1 - servers/nextjs/utils/pptx_models_utils.ts | 255 --- 49 files changed, 874 insertions(+), 9669 deletions(-) delete mode 100644 electron/app/ipc/presentation_to_pptx_model_handlers.ts delete mode 100644 electron/servers/fastapi/models/pptx_models.py delete mode 100644 electron/servers/fastapi/services/html_to_text_runs_service.py delete mode 100644 electron/servers/fastapi/services/pptx_presentation_creator.py delete mode 100644 electron/servers/fastapi/tests/test_pptx_creator.py delete mode 100644 electron/servers/fastapi/utils/image_utils.py delete mode 100644 electron/servers/nextjs/app/api/export-as-pdf/route.ts delete mode 100644 electron/servers/nextjs/app/api/presentation_to_pptx_model/route.ts delete mode 100644 electron/servers/nextjs/types/element_attibutes.ts delete mode 100644 electron/servers/nextjs/types/pptx_models.ts delete mode 100644 electron/servers/nextjs/utils/pptx_models_utils.ts delete mode 100644 servers/fastapi/models/pptx_models.py delete mode 100644 servers/fastapi/services/html_to_text_runs_service.py delete mode 100644 servers/fastapi/services/pptx_presentation_creator.py delete mode 100644 servers/fastapi/tests/test_pptx_creator.py delete mode 100644 servers/fastapi/utils/image_utils.py rename servers/nextjs/app/api/{export-as-pdf => export-presentation}/route.ts (53%) delete mode 100644 servers/nextjs/app/api/presentation_to_pptx_model/route.ts create mode 100644 servers/nextjs/lib/compile-template-schema.ts rename servers/nextjs/lib/{run-bundled-pdf-export.ts => run-bundled-presentation-export.ts} (94%) delete mode 100644 servers/nextjs/types/element_attibutes.ts delete mode 100644 servers/nextjs/types/pptx_models.ts delete mode 100644 servers/nextjs/utils/pptx_models_utils.ts diff --git a/Dockerfile b/Dockerfile index 899bea62..c44d714d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,8 +31,7 @@ FROM node:20-bookworm-slim AS nextjs-builder WORKDIR /app/servers/nextjs -ENV NEXT_TELEMETRY_DISABLED=1 \ - PUPPETEER_SKIP_DOWNLOAD=true +ENV NEXT_TELEMETRY_DISABLED=1 COPY servers/nextjs/package.json servers/nextjs/package-lock.json ./ RUN --mount=type=cache,target=/root/.npm \ @@ -71,14 +70,12 @@ FROM python:3.11-slim-trixie AS runtime WORKDIR /app -ARG INSTALL_CHROMIUM=true ARG INSTALL_TESSERACT=true ARG INSTALL_LIBREOFFICE=true # LiteParse uses Node + @llamaindex/liteparse (same runner as Electron); OCR uses Tesseract. ENV APP_DATA_DIRECTORY=/app_data \ TEMP_DIRECTORY=/tmp/presenton \ - PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \ EXPORT_PACKAGE_ROOT=/app/presentation-export \ EXPORT_RUNTIME_DIR=/app/presentation-export \ BUILT_PYTHON_MODULE_PATH=/app/presentation-export/py/convert-linux-x64 \ @@ -90,7 +87,6 @@ ENV APP_DATA_DIRECTORY=/app_data \ RUN set -eux; \ packages="ca-certificates curl nginx fontconfig imagemagick zstd"; \ if [ "$INSTALL_LIBREOFFICE" = "true" ]; then packages="$packages libreoffice"; fi; \ - if [ "$INSTALL_CHROMIUM" = "true" ]; then packages="$packages chromium"; fi; \ if [ "$INSTALL_TESSERACT" = "true" ]; then packages="$packages tesseract-ocr tesseract-ocr-eng"; fi; \ apt-get update; \ apt-get install -y --no-install-recommends $packages; \ diff --git a/electron/app/ipc/export_handlers.ts b/electron/app/ipc/export_handlers.ts index f1d4271c..7538d93c 100644 --- a/electron/app/ipc/export_handlers.ts +++ b/electron/app/ipc/export_handlers.ts @@ -20,7 +20,7 @@ export function setupExportHandlers() { return { success }; }); - ipcMain.handle("export-presentation", async (_, id: string, title: string, exportAs: "pptx" | "pdf" | "png") => { + ipcMain.handle("export-presentation", async (_, id: string, title: string, exportAs: "pptx" | "pdf") => { try { const params = new URLSearchParams({ id }); if (process.env.NEXT_PUBLIC_FAST_API) { @@ -258,4 +258,4 @@ async function moveFile(sourcePath: string, destinationPath: string) { await fs.promises.copyFile(sourcePath, destinationPath); await fs.promises.unlink(sourcePath); } -} \ No newline at end of file +} diff --git a/electron/app/ipc/index.ts b/electron/app/ipc/index.ts index 7e470097..11193a3a 100644 --- a/electron/app/ipc/index.ts +++ b/electron/app/ipc/index.ts @@ -4,20 +4,18 @@ import { setupSlideMetadataHandlers } from "./slide_metadata"; import { setupReadFile } from "./read_file"; import { setupFooterHandlers } from "./footer_handlers"; import { setupThemeHandlers } from "./theme_handlers"; -import { setupUploadImage } from "./upload_image"; -import { setupLogHandler } from "./log_handler"; -import { setupApiHandlers } from "./api_handlers"; -import { setupPresentationToPptxModelHandlers } from "./presentation_to_pptx_model_handlers"; - -export function setupIpcHandlers() { +import { setupUploadImage } from "./upload_image"; +import { setupLogHandler } from "./log_handler"; +import { setupApiHandlers } from "./api_handlers"; + +export function setupIpcHandlers() { setupExportHandlers(); setupUserConfigHandlers(); setupSlideMetadataHandlers(); setupReadFile(); setupFooterHandlers(); setupThemeHandlers(); - setupUploadImage(); - setupLogHandler(); - setupApiHandlers(); - setupPresentationToPptxModelHandlers(); -} \ No newline at end of file + setupUploadImage(); + setupLogHandler(); + setupApiHandlers(); +} diff --git a/electron/app/ipc/presentation_to_pptx_model_handlers.ts b/electron/app/ipc/presentation_to_pptx_model_handlers.ts deleted file mode 100644 index a5334c53..00000000 --- a/electron/app/ipc/presentation_to_pptx_model_handlers.ts +++ /dev/null @@ -1,1935 +0,0 @@ -import { ipcMain, BrowserWindow } from "electron"; -import * as path from "path"; -import * as fs from "fs"; -import { v4 as uuidv4 } from "uuid"; -import sharp from "sharp"; - -interface ElementAttributes { - tagName: string; - id?: string; - className?: string; - innerText?: string; - opacity?: number; - background?: { - color?: string; - opacity?: number; - }; - border?: { - color?: string; - width?: number; - opacity?: number; - }; - shadow?: { - offset?: [number, number]; - color?: string; - opacity?: number; - radius?: number; - angle?: number; - spread?: number; - inset?: boolean; - }; - font?: { - name?: string; - size?: number; - weight?: number; - color?: string; - italic?: boolean; - }; - position?: { - left: number; - top: number; - width: number; - height: number; - }; - margin?: { - top?: number; - bottom?: number; - left?: number; - right?: number; - }; - padding?: { - top?: number; - bottom?: number; - left?: number; - right?: number; - }; - zIndex?: number; - textAlign?: "left" | "center" | "right" | "justify"; - lineHeight?: number; - borderRadius?: number[]; - imageSrc?: string; - objectFit?: "contain" | "cover" | "fill"; - clip?: boolean; - overlay?: any; - shape?: "rectangle" | "circle"; - connectorType?: any; - textWrap?: boolean; - should_screenshot?: boolean; - filters?: { - invert?: number; - brightness?: number; - contrast?: number; - saturate?: number; - hueRotate?: number; - blur?: number; - grayscale?: number; - sepia?: number; - opacity?: number; - }; -} - -interface SlideAttributesResult { - elements: ElementAttributes[]; - backgroundColor?: string; - speakerNote?: string; -} - -interface PptxSlide { - shapes: any[]; - background?: { - color: string; - opacity?: number; - }; - note?: string; -} - -interface PptxPresentationModel { - slides: PptxSlide[]; -} - -class ApiError extends Error { - constructor(message: string) { - super(message); - this.name = "ApiError"; - } -} - -export function setupPresentationToPptxModelHandlers() { - ipcMain.handle( - "presentation-to-pptx-model", - async (event, presentationId: string) => { - console.log('[PPTX Export] ========================================'); - console.log('[PPTX Export] Starting PPTX export for presentation:', presentationId); - console.log('[PPTX Export] ========================================'); - let window: BrowserWindow | null = null; - - try { - console.log('[PPTX Export] Step 1: Getting screenshots directory...'); - const screenshotsDir = getScreenshotsDir(); - console.log('[PPTX Export] Screenshots directory:', screenshotsDir); - - console.log('[PPTX Export] Step 2: Creating browser window...'); - window = await createBrowserWindow(presentationId); - - console.log('[PPTX Export] Step 3: Getting slides and speaker notes...'); - const { slides, speakerNotes } = await getSlidesAndSpeakerNotes(window); - console.log('[PPTX Export] Found', slides.length, 'slides and', speakerNotes.length, 'speaker notes'); - - console.log('[PPTX Export] Step 4: Getting slides attributes...'); - const slides_attributes = await getSlidesAttributes( - window, - slides, - screenshotsDir - ); - console.log('[PPTX Export] Got attributes for', slides_attributes.length, 'slides'); - - console.log('[PPTX Export] Step 5: Post-processing slides attributes...'); - await postProcessSlidesAttributes( - window, - slides_attributes, - screenshotsDir, - speakerNotes - ); - - console.log('[PPTX Export] Step 6: Converting to PPTX model...'); - const slides_pptx_models = - convertElementAttributesToPptxSlides(slides_attributes); - const presentation_pptx_model: PptxPresentationModel = { - slides: slides_pptx_models, - }; - - console.log('[PPTX Export] Step 7: Closing browser window...'); - window.close(); - - console.log('[PPTX Export] ========================================'); - console.log('[PPTX Export] Export completed successfully!'); - console.log('[PPTX Export] ========================================'); - return { success: true, data: presentation_pptx_model }; - } catch (error: any) { - console.error('[PPTX Export] ========================================'); - console.error('[PPTX Export] Export failed with error:', error); - console.error('[PPTX Export] ========================================'); - if (window) { - console.log('[PPTX Export] Closing browser window due to error...'); - window.close(); - } - return { - success: false, - error: error.message, - isApiError: error instanceof ApiError, - }; - } - } - ); -} - -async function createBrowserWindow( - presentationId: string -): Promise { - console.log('[PPTX Export] Creating browser window for presentation:', presentationId); - - // Use the Next.js URL from environment variable - const nextjsUrl = process.env.NEXT_PUBLIC_URL || 'http://localhost:3000'; - const fastApiUrl = process.env.NEXT_PUBLIC_FAST_API || 'http://127.0.0.1:8000'; - - console.log('[PPTX Export] Next.js URL:', nextjsUrl); - console.log('[PPTX Export] FastAPI URL:', fastApiUrl); - - // Get the preload script path - const preloadPath = path.join(__dirname, '../preloads/pptx-export.js'); - console.log('[PPTX Export] Preload script path:', preloadPath); - console.log('[PPTX Export] Preload script exists:', fs.existsSync(preloadPath)); - - const window = new BrowserWindow({ - width: 1280, - height: 720, - show: false, - webPreferences: { - nodeIntegration: false, - contextIsolation: false, // Disabled so preload can set window.env - webSecurity: false, - preload: preloadPath, - }, - }); - - const url = `${nextjsUrl}/pdf-maker?id=${presentationId}`; - console.log('[PPTX Export] Loading URL:', url); - await window.loadURL(url); - console.log('[PPTX Export] URL loaded, waiting for page to finish loading...'); - - // Wait for page to finish loading first - await new Promise((resolve) => { - const checkLoadState = () => { - if (window.webContents.isLoading()) { - setTimeout(checkLoadState, 100); - } else { - console.log('[PPTX Export] Page finished loading (isLoading = false)'); - resolve(); - } - }; - checkLoadState(); - }); - - // Check readyState - const readyState = await window.webContents.executeJavaScript('document.readyState'); - console.log('[PPTX Export] Document readyState:', readyState); - - // Check initial page state and verify environment variables - const initialPageState = await window.webContents.executeJavaScript(` - ({ - title: document.title, - url: window.location.href, - hasNextScripts: document.querySelectorAll('script[src*="next"], script[src*="_next"]').length, - bodyChildren: document.body ? document.body.children.length : 0, - hasWindowEnv: !!window.env, - fastApiUrl: window.env ? window.env.NEXT_PUBLIC_FAST_API : 'not set' - }) - `); - console.log('[PPTX Export] Initial page state:', initialPageState); - - // Wait for Next.js to finish loading (check for __NEXT_DATA__ or hydration) - console.log('[PPTX Export] Waiting for Next.js to finish loading...'); - try { - await window.webContents.executeJavaScript(` - new Promise((resolve) => { - let attempts = 0; - const maxAttempts = 50; // 10 seconds - - const checkNextJS = () => { - attempts++; - const hasNextData = !!window.__NEXT_DATA__; - const hasHydrated = document.body && document.body.getAttribute('data-reactroot') !== null; - const scriptsLoaded = document.querySelectorAll('script[src*="_next"]').length > 0; - - if (hasNextData || hasHydrated || scriptsLoaded || attempts >= maxAttempts) { - console.log('[PPTX Export] Next.js check complete: hasNextData=' + hasNextData + ', hasHydrated=' + hasHydrated + ', scriptsLoaded=' + scriptsLoaded + ', attempts=' + attempts); - resolve(); - return; - } - - setTimeout(checkNextJS, 200); - }; - - checkNextJS(); - }); - `); - } catch (e) { - console.warn('[PPTX Export] Error checking Next.js state:', e); - } - - // Wait for page to be fully loaded and slides wrapper to be ready - console.log('[PPTX Export] Starting to wait for presentation-slides-wrapper...'); - try { - const waitResult = await window.webContents.executeJavaScript(` - new Promise((resolve, reject) => { - let attempts = 0; - const maxAttempts = 150; // 30 seconds with 200ms intervals - - const checkForElement = () => { - attempts++; - const wrapper = document.getElementById('presentation-slides-wrapper'); - const hasBody = !!document.body; - const bodyChildren = document.body ? document.body.children.length : 0; - const currentUrl = window.location.href; - const pageTitle = document.title; - - if (attempts % 10 === 0 || attempts <= 5) { - console.log('[PPTX Export] Attempt ' + attempts + ': wrapper=' + (wrapper ? 'found' : 'not found') + ', body=' + hasBody + ', bodyChildren=' + bodyChildren + ', url=' + currentUrl + ', title=' + pageTitle); - - // On first few attempts, log what's actually in the body - if (attempts <= 3 && document.body) { - const firstChild = document.body.firstElementChild; - if (firstChild) { - console.log('[PPTX Export] First body child: tag=' + firstChild.tagName + ', id=' + (firstChild.id || 'no-id') + ', class=' + (firstChild.className || 'no-class')); - } - } - } - - if (wrapper) { - // Check if it actually has slides inside - const slides = wrapper.querySelectorAll(':scope > div > div'); - if (slides.length > 0) { - console.log('[PPTX Export] Found presentation-slides-wrapper with ' + slides.length + ' slides after ' + attempts + ' attempts'); - resolve({ found: true, slidesCount: slides.length, attempts: attempts }); - return; - } else { - if (attempts % 10 === 0) { - console.log('[PPTX Export] Wrapper found but has 0 slides'); - } - } - } - - if (attempts >= maxAttempts) { - console.error('[PPTX Export] Timeout after ' + attempts + ' attempts. Wrapper exists:', !!document.getElementById('presentation-slides-wrapper')); - // Get detailed DOM info for debugging - const domInfo = { - readyState: document.readyState, - title: document.title, - hasBody: !!document.body, - bodyChildren: document.body ? document.body.children.length : 0, - bodyHTML: document.body ? document.body.innerHTML.substring(0, 500) : 'no body', - allIds: Array.from(document.querySelectorAll('[id]')).map(el => el.id), - allClasses: Array.from(document.querySelectorAll('[class]')).slice(0, 20).map(el => el.className), - rootElement: document.documentElement ? document.documentElement.tagName : 'none', - url: window.location.href - }; - console.error('[PPTX Export] DOM state at timeout:', JSON.stringify(domInfo, null, 2)); - reject(new Error('Timeout waiting for presentation-slides-wrapper with slides')); - return; - } - - setTimeout(checkForElement, 200); - }; - - // Also use MutationObserver for faster detection - const observer = new MutationObserver(() => { - const wrapper = document.getElementById('presentation-slides-wrapper'); - if (wrapper) { - const slides = wrapper.querySelectorAll(':scope > div > div'); - if (slides.length > 0) { - console.log('[PPTX Export] MutationObserver detected slides!'); - observer.disconnect(); - resolve({ found: true, slidesCount: slides.length, attempts: attempts, viaObserver: true }); - } - } - }); - - if (document.body) { - observer.observe(document.body, { childList: true, subtree: true }); - console.log('[PPTX Export] MutationObserver started'); - } else { - console.warn('[PPTX Export] document.body is null, cannot start MutationObserver'); - } - - // Start checking - console.log('[PPTX Export] Starting polling for presentation-slides-wrapper...'); - checkForElement(); - }); - `); - console.log('[PPTX Export] Wait completed:', waitResult); - } catch (error) { - console.error('[PPTX Export] Error waiting for slides wrapper:', error); - // Check what's actually in the DOM - try { - const domInfo = await window.webContents.executeJavaScript(` - (function() { - const body = document.body; - const wrapper = document.getElementById('presentation-slides-wrapper'); - - // Get all elements with common class names that might contain slides - const possibleContainers = Array.from(document.querySelectorAll('[class*="slide"], [class*="presentation"], [class*="wrapper"]')).slice(0, 10); - - return { - readyState: document.readyState, - title: document.title, - url: window.location.href, - hasBody: !!body, - bodyChildren: body ? body.children.length : 0, - bodyFirstChild: body && body.firstElementChild ? body.firstElementChild.tagName + (body.firstElementChild.id ? '#' + body.firstElementChild.id : '') + (body.firstElementChild.className ? '.' + body.firstElementChild.className.split(' ')[0] : '') : 'none', - hasWrapper: !!wrapper, - wrapperChildren: wrapper ? wrapper.children.length : 0, - allIds: Array.from(document.querySelectorAll('[id]')).map(el => el.id).slice(0, 30), - possibleContainers: possibleContainers.map(el => ({ - tag: el.tagName, - id: el.id || 'no-id', - className: el.className || 'no-class', - children: el.children.length - })), - bodyHTMLPreview: body ? body.innerHTML.substring(0, 1000) : 'no body' - }; - })() - `); - console.log('[PPTX Export] Detailed DOM state when error occurred:'); - console.log(JSON.stringify(domInfo, null, 2)); - - // Also check if there are any console errors - const consoleMessages = await window.webContents.executeJavaScript(` - (function() { - // This won't capture past errors, but we can check for error indicators - return { - hasErrorElements: document.querySelectorAll('[class*="error"], [class*="Error"]').length, - hasLoadingElements: document.querySelectorAll('[class*="loading"], [class*="Loading"], [class*="spinner"]').length, - hasNextScripts: document.querySelectorAll('script[src*="next"], script[src*="_next"]').length - }; - })() - `); - console.log('[PPTX Export] Page indicators:', consoleMessages); - } catch (e) { - console.error('[PPTX Export] Could not get DOM info:', e); - } - // Continue anyway, we'll check in the next step - } - - // Additional wait for images and content to load - console.log('[PPTX Export] Additional 2 second wait for content to load...'); - await new Promise((resolve) => setTimeout(resolve, 2000)); - console.log('[PPTX Export] Browser window ready'); - - return window; -} - -function getScreenshotsDir(): string { - const tempDir = process.env.TEMP_DIRECTORY; - if (!tempDir) { - throw new ApiError("TEMP_DIRECTORY environment variable not set"); - } - const screenshotsDir = path.join(tempDir, "screenshots"); - if (!fs.existsSync(screenshotsDir)) { - fs.mkdirSync(screenshotsDir, { recursive: true }); - } - return screenshotsDir; -} - -async function getSlidesAndSpeakerNotes(window: BrowserWindow) { - try { - console.log('[PPTX Export] Getting slides and speaker notes...'); - // Wait for webContents to be ready - if (!window.webContents) { - throw new ApiError('Window webContents not available'); - } - - // Retry logic with exponential backoff - let lastError: Error | null = null; - for (let attempt = 0; attempt < 5; attempt++) { - try { - // Wait a bit before each attempt (except first) - if (attempt > 0) { - const waitTime = 1000 * attempt; - console.log(`[PPTX Export] Retry attempt ${attempt + 1}/5, waiting ${waitTime}ms...`); - await new Promise((resolve) => setTimeout(resolve, waitTime)); - } else { - console.log('[PPTX Export] First attempt to get slides...'); - } - - const result = await window.webContents.executeJavaScript(` - (function() { - try { - console.log('[PPTX Export] Checking for presentation-slides-wrapper...'); - const slidesWrapper = document.getElementById('presentation-slides-wrapper'); - if (!slidesWrapper) { - console.warn('[PPTX Export] presentation-slides-wrapper not found'); - return { - error: 'Presentation slides not found', - slidesCount: 0, - speakerNotes: [] - }; - } - - console.log('[PPTX Export] Found wrapper, getting speaker notes and slides...'); - const speakerNotes = Array.from(slidesWrapper.querySelectorAll('[data-speaker-note]')).map( - (el) => el.getAttribute('data-speaker-note') || '' - ); - - const slides = Array.from(slidesWrapper.querySelectorAll(':scope > div > div')); - console.log('[PPTX Export] Found ' + slides.length + ' slides and ' + speakerNotes.length + ' speaker notes'); - - if (slides.length === 0) { - console.warn('[PPTX Export] Wrapper found but has 0 slides'); - return { - error: 'No slides found in presentation-slides-wrapper', - slidesCount: 0, - speakerNotes: [] - }; - } - - return { - slidesCount: slides.length, - speakerNotes: speakerNotes - }; - } catch (error) { - console.error('[PPTX Export] Error in getSlidesAndSpeakerNotes:', error); - return { - error: error.message || String(error), - slidesCount: 0, - speakerNotes: [] - }; - } - })(); - `); - - console.log('[PPTX Export] Result from attempt', attempt + 1, ':', result); - - if (result.error) { - lastError = new ApiError(`Failed to get slides data: ${result.error}`); - console.warn(`[PPTX Export] Attempt ${attempt + 1} failed:`, result.error); - if (attempt < 4) { - continue; // Retry - } - throw lastError; - } - - if (!result.slidesCount || result.slidesCount === 0) { - lastError = new ApiError('No slides found in presentation'); - console.warn(`[PPTX Export] Attempt ${attempt + 1} found 0 slides`); - if (attempt < 4) { - continue; // Retry - } - throw lastError; - } - - console.log(`[PPTX Export] Successfully got ${result.slidesCount} slides on attempt ${attempt + 1}`); - return { - slides: Array(result.slidesCount) - .fill(null) - .map((_, i) => i), - speakerNotes: result.speakerNotes || [], - }; - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - console.error(`[PPTX Export] Attempt ${attempt + 1} threw error:`, lastError.message); - if (attempt < 4) { - continue; // Retry - } - throw lastError; - } - } - - // Should never reach here, but just in case - throw lastError || new ApiError('Failed to get slides data after multiple attempts'); - } catch (error) { - console.error('[PPTX Export] Error executing JavaScript in slides page:', error); - const errorMessage = error instanceof Error ? error.message : String(error); - throw new ApiError(`Failed to get slides data: ${errorMessage}`); - } -} - -async function getSlidesAttributes( - window: BrowserWindow, - slides: number[], - screenshotsDir: string -): Promise { - const slideAttributes: SlideAttributesResult[] = []; - - for (const slideIndex of slides) { - const attributes = await getAllChildElementsAttributes( - window, - slideIndex, - screenshotsDir - ); - slideAttributes.push(attributes); - } - - return slideAttributes; -} - -async function getAllChildElementsAttributes( - window: BrowserWindow, - slideIndex: number, - screenshotsDir: string -): Promise { - try { - const result = await window.webContents.executeJavaScript(` - (function() { - try { - const slidesWrapper = document.getElementById('presentation-slides-wrapper'); - const slides = Array.from(slidesWrapper.querySelectorAll(':scope > div > div')); - const slide = slides[${slideIndex}]; - - if (!slide) { - throw new Error('Slide not found at index ' + ${slideIndex}); - } - - ${getElementAttributesFunction()} - - function getAllChildElementsAttributesRecursive(element, rootRect, depth, inheritedFont, inheritedBackground, inheritedBorderRadius, inheritedZIndex, inheritedOpacity) { - if (!rootRect) { - const rootAttributes = getElementAttributes(element); - inheritedFont = rootAttributes.font; - inheritedBackground = rootAttributes.background; - inheritedZIndex = rootAttributes.zIndex; - inheritedOpacity = rootAttributes.opacity; - rootRect = { - left: rootAttributes.position?.left ?? 0, - top: rootAttributes.position?.top ?? 0, - width: rootAttributes.position?.width ?? 1280, - height: rootAttributes.position?.height ?? 720, - }; - depth = 0; - } - - const directChildren = Array.from(element.children); - const allResults = []; - - for (const child of directChildren) { - const attributes = getElementAttributes(child); - - if (['style', 'script', 'link', 'meta', 'path'].includes(attributes.tagName)) { - continue; - } - - if (inheritedFont && !attributes.font && attributes.innerText && attributes.innerText.trim().length > 0) { - attributes.font = inheritedFont; - } - if (inheritedBackground && !attributes.background && attributes.shadow) { - attributes.background = inheritedBackground; - } - if (inheritedBorderRadius && !attributes.borderRadius) { - attributes.borderRadius = inheritedBorderRadius; - } - if (inheritedZIndex !== undefined && attributes.zIndex === 0) { - attributes.zIndex = inheritedZIndex; - } - if (inheritedOpacity !== undefined && (attributes.opacity === undefined || attributes.opacity === 1)) { - attributes.opacity = inheritedOpacity; - } - - 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, - width: attributes.position.width, - height: attributes.position.height, - }; - } - - if (!attributes.position || !attributes.position.width || !attributes.position.height || - attributes.position.width === 0 || attributes.position.height === 0) { - continue; - } - - if (attributes.tagName === 'p') { - const innerElementTagNames = Array.from(child.querySelectorAll('*')).map((e) => - e.tagName.toLowerCase() - ); - - const allowedInlineTags = new Set(['strong', 'u', 'em', 'code', 's']); - const hasOnlyAllowedInlineTags = innerElementTagNames.every((tag) => - allowedInlineTags.has(tag) - ); - - if (innerElementTagNames.length > 0 && hasOnlyAllowedInlineTags) { - attributes.innerText = child.innerHTML; - allResults.push({ attributes, depth }); - continue; - } - } - - if (attributes.tagName === 'svg' || attributes.tagName === 'canvas' || attributes.tagName === 'table') { - attributes.should_screenshot = true; - attributes.elementIndex = allResults.length; - } - - allResults.push({ attributes, depth }); - - if (attributes.should_screenshot && attributes.tagName !== 'svg') { - continue; - } - - const childResults = getAllChildElementsAttributesRecursive( - child, - rootRect, - depth + 1, - attributes.font || inheritedFont, - attributes.background || inheritedBackground, - attributes.borderRadius || inheritedBorderRadius, - attributes.zIndex || inheritedZIndex, - attributes.opacity || inheritedOpacity - ); - - 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 && - attributes.position.left === 0 && - attributes.position.top === 0 && - attributes.position.width === rootRect.width && - attributes.position.height === rootRect.height - ); - }); - - for (const { attributes } of elementsWithRootPosition) { - if (attributes.background && attributes.background.color) { - backgroundColor = attributes.background.color; - break; - } - } - } - - 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 hasVisualProperties = hasBackground || hasBorder || hasShadow || hasText; - const hasSpecialContent = hasImage || isSvg || isCanvas || isTable; - - return (hasVisualProperties && !occupiesRoot) || hasSpecialContent; - }) - : allResults; - - 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 a.depth - b.depth; - } - - return zIndexB - zIndexA; - }) - .map(({ attributes }) => { - if ( - attributes.shadow && - attributes.shadow.color && - (!attributes.background || !attributes.background.color) && - backgroundColor - ) { - attributes.background = { - color: backgroundColor, - opacity: undefined, - }; - } - return attributes; - }); - - return { - elements: sortedElements, - backgroundColor: backgroundColor, - }; - } else { - return { - elements: filteredResults.map(({ attributes }) => attributes), - backgroundColor: backgroundColor, - }; - } - } - - return getAllChildElementsAttributesRecursive(slide, null, 0, undefined, undefined, undefined, undefined, undefined); - } catch (error) { - console.error('Error in slide processing:', error); - return { - error: error.message || String(error), - elements: [], - backgroundColor: undefined - }; - } - })(); - `); - - if (result.error) { - throw new ApiError(`Failed to analyze slide ${slideIndex}: ${result.error}`); - } - - return result; - } catch (error) { - console.error(`Error getting attributes for slide ${slideIndex}:`, error); - const errorMessage = error instanceof Error ? error.message : String(error); - throw new ApiError(`Failed to analyze slide ${slideIndex}: ${errorMessage}`); - } -} - -function getElementAttributesFunction(): string { - return ` - function getElementAttributes(el) { - function colorToHex(color) { - if (!color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)') { - return { hex: undefined, opacity: undefined }; - } - - if (color.startsWith('rgba(') || color.startsWith('hsla(')) { - const match = color.match(/rgba?\\(([^)]+)\\)|hsla?\\(([^)]+)\\)/); - if (match) { - const values = match[1] || match[2]; - const parts = values.split(',').map((part) => part.trim()); - - if (parts.length >= 4) { - const opacity = parseFloat(parts[3]); - const rgbColor = color.replace(/rgba?\\(|hsla?\\(|\\)/g, '').split(',').slice(0, 3).join(','); - const rgbString = color.startsWith('rgba') ? 'rgb(' + rgbColor + ')' : 'hsl(' + rgbColor + ')'; - - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.fillStyle = rgbString; - const hexColor = ctx.fillStyle; - const hex = hexColor.startsWith('#') ? hexColor.substring(1) : hexColor; - return { - hex: hex, - opacity: isNaN(opacity) ? undefined : opacity, - }; - } - } - } - } - - if (color.startsWith('rgb(') || color.startsWith('hsl(')) { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.fillStyle = color; - const hexColor = ctx.fillStyle; - const hex = hexColor.startsWith('#') ? hexColor.substring(1) : hexColor; - return { hex: hex, opacity: undefined }; - } - } - - if (color.startsWith('#')) { - const hex = color.substring(1); - return { hex: hex, opacity: undefined }; - } - - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - if (!ctx) return { hex: color, opacity: undefined }; - - ctx.fillStyle = color; - const hexColor = ctx.fillStyle; - const hex = hexColor.startsWith('#') ? hexColor.substring(1) : hexColor; - return { hex: hex, opacity: undefined }; - } - - function hasOnlyTextNodes(el) { - const children = el.childNodes; - for (let i = 0; i < children.length; i++) { - const child = children[i]; - if (child.nodeType === Node.ELEMENT_NODE) { - return false; - } - } - return true; - } - - function parsePosition(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, - }; - } - - function parseBackground(computedStyles) { - const backgroundColorResult = colorToHex(computedStyles.backgroundColor); - - const background = { - color: backgroundColorResult.hex, - opacity: backgroundColorResult.opacity, - }; - - if (!background.color && background.opacity === undefined) { - return undefined; - } - - return background; - } - - function parseBackgroundImage(computedStyles) { - const backgroundImage = computedStyles.backgroundImage; - - if (!backgroundImage || backgroundImage === 'none') { - return undefined; - } - - const urlMatch = backgroundImage.match(/url\\(['"]?([^'"]+)['"]?\\)/); - if (urlMatch && urlMatch[1]) { - return urlMatch[1]; - } - - return undefined; - } - - function parseBorder(computedStyles) { - const borderColorResult = colorToHex(computedStyles.borderColor); - const borderWidth = parseFloat(computedStyles.borderWidth); - - if (borderWidth === 0) { - return undefined; - } - - const border = { - color: borderColorResult.hex, - width: isNaN(borderWidth) ? undefined : borderWidth, - opacity: borderColorResult.opacity, - }; - - if (!border.color && border.width === undefined && border.opacity === undefined) { - return undefined; - } - - return border; - } - - function parseShadow(computedStyles) { - const boxShadow = computedStyles.boxShadow; - let shadow = {}; - - if (boxShadow && boxShadow !== 'none') { - const shadows = []; - let currentShadow = ''; - let parenCount = 0; - - for (let i = 0; i < boxShadow.length; i++) { - const char = boxShadow[i]; - if (char === '(') { - parenCount++; - } else if (char === ')') { - parenCount--; - } else if (char === ',' && parenCount === 0) { - shadows.push(currentShadow.trim()); - currentShadow = ''; - continue; - } - currentShadow += char; - } - - if (currentShadow.trim()) { - shadows.push(currentShadow.trim()); - } - - let selectedShadow = ''; - let bestShadowScore = -1; - - for (let i = 0; i < shadows.length; i++) { - const shadowStr = shadows[i]; - - const shadowParts = shadowStr.split(' '); - const numericParts = []; - const colorParts = []; - let isInset = false; - let currentColor = ''; - let inColorFunction = false; - - for (let j = 0; j < shadowParts.length; j++) { - const part = shadowParts[j]; - const trimmedPart = part.trim(); - if (trimmedPart === '') continue; - - if (trimmedPart.toLowerCase() === 'inset') { - isInset = true; - continue; - } - - if (trimmedPart.match(/^(rgba?|hsla?)\\s*\\(/i)) { - inColorFunction = true; - currentColor = trimmedPart; - continue; - } - - if (inColorFunction) { - currentColor += ' ' + trimmedPart; - - const openParens = (currentColor.match(/\\(/g) || []).length; - const closeParens = (currentColor.match(/\\)/g) || []).length; - - if (openParens <= closeParens) { - colorParts.push(currentColor); - currentColor = ''; - inColorFunction = false; - } - continue; - } - - const numericValue = parseFloat(trimmedPart); - if (!isNaN(numericValue)) { - numericParts.push(numericValue); - } else { - colorParts.push(trimmedPart); - } - } - - let hasVisibleColor = false; - if (colorParts.length > 0) { - const shadowColor = colorParts.join(' '); - const colorResult = colorToHex(shadowColor); - hasVisibleColor = !!(colorResult.hex && colorResult.hex !== '000000' && colorResult.opacity !== 0); - } - - const hasNonZeroValues = numericParts.some((value) => value !== 0); - - let shadowScore = 0; - if (hasNonZeroValues) { - shadowScore += numericParts.filter((value) => value !== 0).length; - } - if (hasVisibleColor) { - shadowScore += 2; - } - - if ((hasNonZeroValues || hasVisibleColor) && shadowScore > bestShadowScore) { - selectedShadow = shadowStr; - bestShadowScore = shadowScore; - } - } - - if (!selectedShadow && shadows.length > 0) { - selectedShadow = shadows[0]; - } - - if (selectedShadow) { - const shadowParts = selectedShadow.split(' '); - const numericParts = []; - const colorParts = []; - let isInset = false; - let currentColor = ''; - let inColorFunction = false; - - for (let i = 0; i < shadowParts.length; i++) { - const part = shadowParts[i]; - const trimmedPart = part.trim(); - if (trimmedPart === '') continue; - - if (trimmedPart.toLowerCase() === 'inset') { - isInset = true; - continue; - } - - if (trimmedPart.match(/^(rgba?|hsla?)\\s*\\(/i)) { - inColorFunction = true; - currentColor = trimmedPart; - continue; - } - - if (inColorFunction) { - currentColor += ' ' + trimmedPart; - - const openParens = (currentColor.match(/\\(/g) || []).length; - const closeParens = (currentColor.match(/\\)/g) || []).length; - - if (openParens <= closeParens) { - colorParts.push(currentColor); - currentColor = ''; - inColorFunction = false; - } - continue; - } - - const numericValue = parseFloat(trimmedPart); - if (!isNaN(numericValue)) { - numericParts.push(numericValue); - } else { - colorParts.push(trimmedPart); - } - } - - if (numericParts.length >= 2) { - const offsetX = numericParts[0]; - const offsetY = numericParts[1]; - const blurRadius = numericParts.length >= 3 ? numericParts[2] : 0; - const spreadRadius = numericParts.length >= 4 ? numericParts[3] : 0; - - if (colorParts.length > 0) { - const shadowColor = colorParts.join(' '); - const shadowColorResult = colorToHex(shadowColor); - - 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), - }; - } - } - } - } - } - - if (Object.keys(shadow).length === 0) { - return undefined; - } - - return shadow; - } - - function parseFont(computedStyles) { - const fontSize = parseFloat(computedStyles.fontSize); - const fontWeight = parseInt(computedStyles.fontWeight); - const fontColorResult = colorToHex(computedStyles.color); - const fontFamily = computedStyles.fontFamily; - const fontStyle = computedStyles.fontStyle; - - let fontName = undefined; - if (fontFamily !== 'initial') { - const firstFont = fontFamily.split(',')[0].trim().replace(/['"]/g, ''); - fontName = firstFont; - } - - const font = { - name: fontName, - size: isNaN(fontSize) ? undefined : fontSize, - weight: isNaN(fontWeight) ? undefined : fontWeight, - color: fontColorResult.hex, - italic: fontStyle === 'italic', - }; - - if (!font.name && font.size === undefined && font.weight === undefined && !font.color && !font.italic) { - return undefined; - } - - return font; - } - - function parseLineHeight(computedStyles, el) { - const lineHeight = computedStyles.lineHeight; - const innerText = el.textContent || ''; - - const htmlEl = el; - - const fontSize = parseFloat(computedStyles.fontSize); - const computedLineHeight = parseFloat(computedStyles.lineHeight); - - const singleLineHeight = !isNaN(computedLineHeight) ? computedLineHeight : fontSize * 1.2; - - const hasExplicitLineBreaks = innerText.includes('\\n') || innerText.includes('\\r') || innerText.includes('\\r\\n'); - const hasTextWrapping = htmlEl.offsetHeight > singleLineHeight * 2; - const hasOverflow = htmlEl.scrollHeight > htmlEl.clientHeight; - - const isMultiline = hasExplicitLineBreaks || hasTextWrapping || hasOverflow; - - if (isMultiline && lineHeight && lineHeight !== 'normal') { - const parsedLineHeight = parseFloat(lineHeight); - if (!isNaN(parsedLineHeight)) { - return parsedLineHeight; - } - } - - return undefined; - } - - function parseMargin(computedStyles) { - const marginTop = parseFloat(computedStyles.marginTop); - const marginBottom = parseFloat(computedStyles.marginBottom); - const marginLeft = parseFloat(computedStyles.marginLeft); - const marginRight = parseFloat(computedStyles.marginRight); - const marginObj = { - top: isNaN(marginTop) ? undefined : marginTop, - bottom: isNaN(marginBottom) ? undefined : marginBottom, - left: isNaN(marginLeft) ? undefined : marginLeft, - right: isNaN(marginRight) ? undefined : marginRight, - }; - - return marginObj.top === 0 && marginObj.bottom === 0 && marginObj.left === 0 && marginObj.right === 0 - ? undefined - : marginObj; - } - - function parsePadding(computedStyles) { - const paddingTop = parseFloat(computedStyles.paddingTop); - const paddingBottom = parseFloat(computedStyles.paddingBottom); - const paddingLeft = parseFloat(computedStyles.paddingLeft); - const paddingRight = parseFloat(computedStyles.paddingRight); - const paddingObj = { - top: isNaN(paddingTop) ? undefined : paddingTop, - bottom: isNaN(paddingBottom) ? undefined : paddingBottom, - left: isNaN(paddingLeft) ? undefined : paddingLeft, - right: isNaN(paddingRight) ? undefined : paddingRight, - }; - - return paddingObj.top === 0 && paddingObj.bottom === 0 && paddingObj.left === 0 && paddingObj.right === 0 - ? undefined - : paddingObj; - } - - function parseBorderRadius(computedStyles, el) { - const borderRadius = computedStyles.borderRadius; - let borderRadiusValue; - - if (borderRadius && borderRadius !== '0px') { - const radiusParts = borderRadius.split(' ').map((part) => parseFloat(part)); - if (radiusParts.length === 1) { - borderRadiusValue = [radiusParts[0], radiusParts[0], radiusParts[0], radiusParts[0]]; - } else if (radiusParts.length === 2) { - borderRadiusValue = [radiusParts[0], radiusParts[1], radiusParts[0], radiusParts[1]]; - } else if (radiusParts.length === 3) { - borderRadiusValue = [radiusParts[0], radiusParts[1], radiusParts[2], radiusParts[1]]; - } else if (radiusParts.length === 4) { - borderRadiusValue = radiusParts; - } - - if (borderRadiusValue) { - const rect = el.getBoundingClientRect(); - const maxRadiusX = rect.width / 2; - const maxRadiusY = rect.height / 2; - - borderRadiusValue = borderRadiusValue.map((radius, index) => { - const maxRadius = index === 0 || index === 2 ? maxRadiusX : maxRadiusY; - return Math.max(0, Math.min(radius, maxRadius)); - }); - } - } - - return borderRadiusValue; - } - - function parseShape(el, borderRadiusValue) { - if (el.tagName.toLowerCase() === 'img') { - return borderRadiusValue && borderRadiusValue.length === 4 && borderRadiusValue.every((radius) => radius === 50) - ? 'circle' - : 'rectangle'; - } - return undefined; - } - - function parseFilters(computedStyles) { - const filter = computedStyles.filter; - if (!filter || filter === 'none') { - return undefined; - } - - const filters = {}; - - const filterFunctions = filter.match(/[a-zA-Z]+\\([^)]*\\)/g); - if (filterFunctions) { - filterFunctions.forEach((func) => { - const match = func.match(/([a-zA-Z]+)\\(([^)]*)\\)/); - if (match) { - const filterType = match[1]; - const value = parseFloat(match[2]); - - if (!isNaN(value)) { - switch (filterType) { - case 'invert': - filters.invert = value; - break; - case 'brightness': - filters.brightness = value; - break; - case 'contrast': - filters.contrast = value; - break; - case 'saturate': - filters.saturate = value; - break; - case 'hue-rotate': - filters.hueRotate = value; - break; - case 'blur': - filters.blur = value; - break; - case 'grayscale': - filters.grayscale = value; - break; - case 'sepia': - filters.sepia = value; - break; - case 'opacity': - filters.opacity = value; - break; - } - } - } - }); - } - - return Object.keys(filters).length > 0 ? filters : undefined; - } - - function parseElementAttributes(el) { - let tagName = el.tagName.toLowerCase(); - - const computedStyles = window.getComputedStyle(el); - - const position = parsePosition(el); - - const shadow = parseShadow(computedStyles); - - const background = parseBackground(computedStyles); - - const border = parseBorder(computedStyles); - - const font = parseFont(computedStyles); - - const lineHeight = parseLineHeight(computedStyles, el); - - const margin = parseMargin(computedStyles); - - const padding = parsePadding(computedStyles); - - const innerText = hasOnlyTextNodes(el) ? el.textContent || undefined : undefined; - - const zIndex = parseInt(computedStyles.zIndex); - const zIndexValue = isNaN(zIndex) ? 0 : zIndex; - - const textAlign = computedStyles.textAlign; - const objectFit = computedStyles.objectFit; - - const parsedBackgroundImage = parseBackgroundImage(computedStyles); - const imageSrc = el.src || parsedBackgroundImage; - - const borderRadiusValue = parseBorderRadius(computedStyles, el); - - const shape = parseShape(el, borderRadiusValue); - - const textWrap = computedStyles.whiteSpace !== 'nowrap'; - - const filters = parseFilters(computedStyles); - - const opacity = parseFloat(computedStyles.opacity); - const elementOpacity = isNaN(opacity) ? undefined : opacity; - - return { - tagName: tagName, - id: el.id, - className: el.className && typeof el.className === 'string' ? el.className : el.className ? el.className.toString() : undefined, - innerText: innerText, - opacity: elementOpacity, - background: background, - border: border, - shadow: shadow, - font: font, - position: position, - margin: margin, - padding: padding, - zIndex: zIndexValue, - textAlign: textAlign !== 'left' ? textAlign : undefined, - lineHeight: lineHeight, - borderRadius: borderRadiusValue, - imageSrc: imageSrc, - objectFit: objectFit, - clip: false, - overlay: undefined, - shape: shape, - connectorType: undefined, - textWrap: textWrap, - should_screenshot: false, - element: undefined, - filters: filters, - }; - } - - return parseElementAttributes(el); - } - `; -} - -async function postProcessSlidesAttributes( - window: BrowserWindow, - 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( - window, - index, - element, - screenshotsDir - ); - element.imageSrc = screenshotPath; - element.should_screenshot = false; - element.objectFit = "cover"; - } - } - slideAttributes.speakerNote = speakerNotes[index]; - } -} - -async function screenshotElement( - window: BrowserWindow, - slideIndex: number, - element: ElementAttributes, - screenshotsDir: string -): Promise { - const screenshotPath = path.join( - screenshotsDir, - `${uuidv4()}.png` - ) as `${string}.png`; - - // For SVG elements, try Sharp first, then fallback to screenshot - if (element.tagName === "svg") { - - const svgData = await window.webContents.executeJavaScript(` - (function() { - try { - const slidesWrapper = document.getElementById('presentation-slides-wrapper'); - const slides = Array.from(slidesWrapper.querySelectorAll(':scope > div > div')); - const slide = slides[${slideIndex}]; - - if (!slide) { - return { error: 'Slide not found at index ${slideIndex}' }; - } - - // IMPORTANT: Element positions were stored relative to the slide root. - // We need to convert them back to absolute viewport coordinates by - // adding the slide's bounding rect offset. - const slideRect = slide.getBoundingClientRect(); - - // Target position in absolute viewport coordinates - const targetPos = { - left: ${element.position!.left} + slideRect.left, - top: ${element.position!.top} + slideRect.top, - width: ${element.position!.width}, - height: ${element.position!.height} - }; - - // Get all SVG elements in the slide - const allSvgs = Array.from(slide.querySelectorAll('svg')); - - let svgElement = null; - let bestMatch = null; - let bestDistance = Infinity; - const candidateInfo = []; - - for (const svg of allSvgs) { - const rect = svg.getBoundingClientRect(); - const distance = Math.sqrt( - Math.pow(rect.left - targetPos.left, 2) + - Math.pow(rect.top - targetPos.top, 2) + - Math.pow(rect.width - targetPos.width, 2) + - Math.pow(rect.height - targetPos.height, 2) - ); - - candidateInfo.push({ - left: rect.left, - top: rect.top, - width: rect.width, - height: rect.height, - distance: distance - }); - - if (distance < bestDistance) { - bestDistance = distance; - bestMatch = svg; - } - - // If very close match (within 2 pixels in all dimensions), use it - if (Math.abs(rect.left - targetPos.left) < 2 && - Math.abs(rect.top - targetPos.top) < 2 && - Math.abs(rect.width - targetPos.width) < 2 && - Math.abs(rect.height - targetPos.height) < 2) { - svgElement = svg; - break; - } - } - - // If no exact match, use the best match if it's reasonably close - if (!svgElement && bestMatch && bestDistance < 100) { - svgElement = bestMatch; - } - - if (!svgElement) { - return { - error: 'SVG not found', - bestDistance: bestDistance, - svgCount: allSvgs.length, - targetPos: targetPos, - slideRect: { left: slideRect.left, top: slideRect.top, width: slideRect.width, height: slideRect.height }, - candidates: candidateInfo - }; - } - - // Clone the SVG to avoid modifying the original - const clonedSvg = svgElement.cloneNode(true); - - // Get computed styles from the original element and its parent - const computedStyle = window.getComputedStyle(svgElement); - const parentStyle = window.getComputedStyle(svgElement.parentElement); - const fontColor = computedStyle.color || parentStyle.color || '#000000'; - - // Apply the color - clonedSvg.style.color = fontColor; - - // Ensure SVG has proper dimensions - const rect = svgElement.getBoundingClientRect(); - if (!clonedSvg.hasAttribute('width')) { - clonedSvg.setAttribute('width', rect.width.toString()); - } - if (!clonedSvg.hasAttribute('height')) { - clonedSvg.setAttribute('height', rect.height.toString()); - } - - // Ensure viewBox is set - if (!clonedSvg.hasAttribute('viewBox')) { - const width = clonedSvg.getAttribute('width') || rect.width; - const height = clonedSvg.getAttribute('height') || rect.height; - clonedSvg.setAttribute('viewBox', \`0 0 \${width} \${height}\`); - } - - // Replace currentColor with actual color in all elements - const allSvgElements = clonedSvg.querySelectorAll('*'); - allSvgElements.forEach((el) => { - ['stroke', 'fill', 'color'].forEach((attr) => { - const value = el.getAttribute(attr); - if (value === 'currentColor') { - el.setAttribute(attr, fontColor); - } - }); - - // Also check inline styles - const style = el.getAttribute('style'); - if (style && style.includes('currentColor')) { - el.setAttribute('style', style.replace(/currentColor/g, fontColor)); - } - }); - - // Also replace currentColor in the root SVG element - ['stroke', 'fill'].forEach((attr) => { - const value = clonedSvg.getAttribute(attr); - if (value === 'currentColor') { - clonedSvg.setAttribute(attr, fontColor); - } - }); - - return { - success: true, - html: clonedSvg.outerHTML, - hasViewBox: clonedSvg.hasAttribute('viewBox'), - hasDimensions: clonedSvg.hasAttribute('width') && clonedSvg.hasAttribute('height'), - color: fontColor - }; - } catch (error) { - return { - error: 'Exception in SVG extraction: ' + (error.message || String(error)), - stack: error.stack - }; - } - })(); - `); - - if (svgData.success && svgData.html) { - try { - const svgBuffer = Buffer.from(svgData.html); - const pngBuffer = await sharp(svgBuffer) - .resize( - Math.round(element.position!.width!), - Math.round(element.position!.height!), - { - fit: 'contain', - background: { r: 0, g: 0, b: 0, alpha: 0 } - } - ) - .png() - .toBuffer(); - fs.writeFileSync(screenshotPath, pngBuffer); - return screenshotPath; - } catch (error) { - console.warn('[PPTX Export] Sharp SVG conversion failed, falling back to screenshot:', (error as Error).message); - // Fall through to screenshot method as fallback - } - } else if (svgData.error) { - console.warn('[PPTX Export] SVG extraction failed:', svgData.error, '- using screenshot fallback'); - } - } - - // Fallback screenshot method for SVG (when Sharp fails) and other elements (canvas, table) - - // Get the slide's absolute position to convert relative coords back to absolute - const slideRectForScreenshot = await window.webContents.executeJavaScript(` - (function() { - const slidesWrapper = document.getElementById('presentation-slides-wrapper'); - const slides = Array.from(slidesWrapper.querySelectorAll(':scope > div > div')); - const slide = slides[${slideIndex}]; - if (!slide) return { left: 0, top: 0 }; - const rect = slide.getBoundingClientRect(); - return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; - })(); - `); - - // Convert relative element position to absolute viewport position - const absLeft = element.position!.left + slideRectForScreenshot.left; - const absTop = element.position!.top + slideRectForScreenshot.top; - - console.log('[PPTX Export] Slide rect:', slideRectForScreenshot, 'Absolute target:', { left: absLeft, top: absTop }); - - await window.webContents.executeJavaScript(` - (function() { - const slidesWrapper = document.getElementById('presentation-slides-wrapper'); - const slides = Array.from(slidesWrapper.querySelectorAll(':scope > div > div')); - const slide = slides[${slideIndex}]; - - if (!slide) return; - - // Use absolute viewport coordinates for matching - const absLeft = ${absLeft}; - const absTop = ${absTop}; - const targetWidth = ${element.position!.width}; - const targetHeight = ${element.position!.height}; - - const allElements = Array.from(slide.querySelectorAll('*')); - const targetElement = allElements.find((el) => { - const rect = el.getBoundingClientRect(); - return el.tagName.toLowerCase() === '${element.tagName}' && - Math.abs(rect.left - absLeft) < 2 && - Math.abs(rect.top - absTop) < 2 && - Math.abs(rect.width - targetWidth) < 2 && - Math.abs(rect.height - targetHeight) < 2; - }); - - if (!targetElement) return; - - const originalOpacities = new Map(); - const allDocumentElements = document.querySelectorAll('*'); - - allDocumentElements.forEach((elem) => { - const computedStyle = window.getComputedStyle(elem); - originalOpacities.set(elem, computedStyle.opacity); - - if ( - targetElement === elem || - targetElement.contains(elem) || - elem.contains(targetElement) - ) { - elem.style.opacity = computedStyle.opacity || '1'; - return; - } - - elem.style.opacity = '0'; - }); - - targetElement.__restoreStyles = function() { - originalOpacities.forEach((opacity, elem) => { - elem.style.opacity = opacity; - }); - }; - })(); - `); - - // Small delay to ensure styles are applied - await new Promise((resolve) => setTimeout(resolve, 50)); - - // capturePage uses absolute viewport coordinates - const rect = { - x: absLeft, - y: absTop, - width: element.position!.width, - height: element.position!.height, - }; - - const screenshot = await window.webContents.capturePage(rect); - fs.writeFileSync(screenshotPath, screenshot.toPNG()); - - // Restore original opacities - await window.webContents.executeJavaScript(` - (function() { - const slidesWrapper = document.getElementById('presentation-slides-wrapper'); - const slides = Array.from(slidesWrapper.querySelectorAll(':scope > div > div')); - const slide = slides[${slideIndex}]; - - if (!slide) return; - - const absLeft = ${absLeft}; - const absTop = ${absTop}; - const targetWidth = ${element.position!.width}; - const targetHeight = ${element.position!.height}; - - const allElements = Array.from(slide.querySelectorAll('*')); - const targetElement = allElements.find((el) => { - const rect = el.getBoundingClientRect(); - return el.tagName.toLowerCase() === '${element.tagName}' && - Math.abs(rect.left - absLeft) < 2 && - Math.abs(rect.top - absTop) < 2 && - Math.abs(rect.width - targetWidth) < 2 && - Math.abs(rect.height - targetHeight) < 2; - }); - - if (targetElement && targetElement.__restoreStyles) { - targetElement.__restoreStyles(); - } - })(); - `); - - return screenshotPath; -} - -function convertElementAttributesToPptxSlides( - slidesAttributes: SlideAttributesResult[] -): PptxSlide[] { - return slidesAttributes.map((slideAttributes) => { - const shapes = slideAttributes.elements.map(element => { - return convertElementToPptxShape(element); - }).filter(Boolean); - - const slide: PptxSlide = { - shapes: shapes, - note: slideAttributes.speakerNote - }; - - if (slideAttributes.backgroundColor) { - slide.background = { - color: slideAttributes.backgroundColor, - opacity: 1.0 - }; - } - - return slide; - }); -} - -function convertTextAlignToPptxAlignment(textAlign?: string): number | undefined { - if (!textAlign) return undefined; - - // PP_ALIGN enum values: LEFT=1, CENTER=2, RIGHT=3, JUSTIFY=4, DISTRIBUTE=5, THAI_DISTRIBUTE=6, JUSTIFY_LOW=7, MIXED=-2 - switch (textAlign.toLowerCase()) { - case 'left': - case 'start': - return 1; // PP_ALIGN.LEFT - case 'center': - return 2; // PP_ALIGN.CENTER - case 'right': - case 'end': - return 3; // PP_ALIGN.RIGHT - case 'justify': - return 4; // PP_ALIGN.JUSTIFY - default: - return 1; // PP_ALIGN.LEFT - } -} - -function convertLineHeightToRelative(lineHeight?: number, fontSize?: number): number | undefined { - if (!lineHeight) return undefined; - - let calculatedLineHeight = 1.2; - if (lineHeight < 10) { - calculatedLineHeight = lineHeight; - } - - if (fontSize && fontSize > 0) { - calculatedLineHeight = Math.round((lineHeight / fontSize) * 100) / 100; - } - - return calculatedLineHeight - 0.3 -} - -function convertElementToPptxShape(element: ElementAttributes): any | null { - if (!element.position) { - return null; - } - - if (element.tagName === 'img' || (element.className && typeof element.className === 'string' && element.className.includes('image')) || element.imageSrc) { - return convertToPictureBox(element); - } - - if (element.innerText && element.innerText.trim().length > 0) { - // Use AutoShape model if there's background color and border radius - if (element.background?.color && element.borderRadius && element.borderRadius.some(radius => radius > 0)) { - return convertToAutoShapeBox(element); - } - return convertToTextBox(element); - } - - if (element.tagName === 'hr') { - return convertToConnector(element); - } - - return convertToAutoShapeBox(element); -} - -function convertToTextBox(element: ElementAttributes): any { - const position = { - left: Math.round(element.position?.left ?? 0), - top: Math.round(element.position?.top ?? 0), - width: Math.round(element.position?.width ?? 0), - height: Math.round(element.position?.height ?? 0) - }; - - const fill = element.background?.color ? { - color: element.background.color, - opacity: element.background.opacity ?? 1.0 - } : undefined; - - const font = element.font ? { - name: element.font.name ?? "Inter", - size: Math.round(element.font.size ?? 16), - font_weight: element.font.weight ?? 400, - italic: element.font.italic ?? false, - color: element.font.color ?? "000000" - } : undefined; - - const paragraph = { - spacing: undefined, - alignment: convertTextAlignToPptxAlignment(element.textAlign), - font, - line_height: convertLineHeightToRelative(element.lineHeight, element.font?.size), - text: element.innerText - }; - - return { - shape_type: "textbox", - margin: undefined, - fill, - position, - text_wrap: element.textWrap ?? true, - paragraphs: [paragraph] - }; -} - -function convertToAutoShapeBox(element: ElementAttributes): any { - const position = { - left: Math.round(element.position?.left ?? 0), - top: Math.round(element.position?.top ?? 0), - width: Math.round(element.position?.width ?? 0), - height: Math.round(element.position?.height ?? 0) - }; - - const fill = element.background?.color ? { - color: element.background.color, - opacity: element.background.opacity ?? 1.0 - } : undefined; - - const stroke = element.border?.color ? { - color: element.border.color, - thickness: element.border.width ?? 1, - opacity: element.border.opacity ?? 1.0 - } : undefined; - - const shadow = element.shadow?.color ? { - 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, - angle: Math.round(element.shadow.angle ?? 0) - } : undefined; - - const paragraphs = element.innerText ? [{ - spacing: undefined, - alignment: convertTextAlignToPptxAlignment(element.textAlign), - font: element.font ? { - name: element.font.name ?? "Inter", - size: Math.round(element.font.size ?? 16), - font_weight: element.font.weight ?? 400, - italic: element.font.italic ?? false, - color: element.font.color ?? "000000" - } : undefined, - line_height: convertLineHeightToRelative(element.lineHeight, element.font?.size), - text: element.innerText - }] : undefined; - - // Use integer enum values: RECTANGLE = 1, ROUNDED_RECTANGLE = 5 - const shapeType = element.borderRadius ? 5 : 1; - - let borderRadius = undefined; - for (const eachCornerRadius of element.borderRadius ?? []) { - if (eachCornerRadius > 0) { - borderRadius = Math.max(borderRadius ?? 0, eachCornerRadius); - } - } - - return { - shape_type: "autoshape", - type: shapeType, - margin: undefined, - fill, - stroke, - shadow, - position, - text_wrap: element.textWrap ?? true, - border_radius: borderRadius || undefined, - paragraphs - }; -} - -function convertToPictureBox(element: ElementAttributes): any { - const position = { - left: Math.round(element.position?.left ?? 0), - top: Math.round(element.position?.top ?? 0), - width: Math.round(element.position?.width ?? 0), - height: Math.round(element.position?.height ?? 0) - }; - - const objectFit = { - fit: element.objectFit || 'contain' - }; - - const picture = { - is_network: element.imageSrc ? element.imageSrc.startsWith('http') : false, - path: element.imageSrc || '' - }; - - return { - shape_type: "picture", - position, - margin: undefined, - clip: element.clip ?? true, - invert: element.filters?.invert === 1, - opacity: element.opacity, - border_radius: element.borderRadius ? element.borderRadius.map(r => Math.round(r)) : undefined, - shape: element.shape || 'rectangle', - object_fit: objectFit, - picture - }; -} - -function convertToConnector(element: ElementAttributes): any { - const position = { - left: Math.round(element.position?.left ?? 0), - top: Math.round(element.position?.top ?? 0), - width: Math.round(element.position?.width ?? 0), - height: Math.round(element.position?.height ?? 0) - }; - - return { - shape_type: "connector", - type: 1, // STRAIGHT = 1 - position, - thickness: element.border?.width ?? 0.5, - color: element.border?.color || element.background?.color || '000000', - opacity: element.border?.opacity ?? 1.0 - }; -} diff --git a/electron/app/preloads/index.ts b/electron/app/preloads/index.ts index 3a0ac48f..08db45d2 100644 --- a/electron/app/preloads/index.ts +++ b/electron/app/preloads/index.ts @@ -9,11 +9,10 @@ contextBridge.exposeInMainWorld('env', { }); -contextBridge.exposeInMainWorld('electron', { - fileDownloaded: (filePath: string) => ipcRenderer.invoke("file-downloaded", filePath), - exportAsPDF: (id: string, title: string) => ipcRenderer.invoke("export-as-pdf", id, title), - exportPresentation: (id: string, title: string, format: "pptx" | "pdf" | "png") => - ipcRenderer.invoke("export-presentation", id, title, format), +contextBridge.exposeInMainWorld('electron', { + fileDownloaded: (filePath: string) => ipcRenderer.invoke("file-downloaded", filePath), + exportPresentation: (id: string, title: string, format: "pptx" | "pdf") => + ipcRenderer.invoke("export-presentation", id, title, format), getUserConfig: () => ipcRenderer.invoke("get-user-config"), setUserConfig: (userConfig: UserConfig) => ipcRenderer.invoke("set-user-config", userConfig), getCanChangeKeys: () => ipcRenderer.invoke("get-can-change-keys"), @@ -28,11 +27,10 @@ contextBridge.exposeInMainWorld('electron', { writeNextjsLog: (logData: string) => ipcRenderer.invoke("write-nextjs-log", logData), clearNextjsLogs: () => ipcRenderer.invoke("clear-nextjs-logs"), // API handlers - hasRequiredKey: () => ipcRenderer.invoke("api:has-required-key"), - telemetryStatus: () => ipcRenderer.invoke("api:telemetry-status"), - getTemplates: () => ipcRenderer.invoke("api:templates"), - getPresentationPptxModel: (presentationId: string) => ipcRenderer.invoke("presentation-to-pptx-model", presentationId), - onStartupStatus: (callback: (payload: { name: string; status: string }) => void) => - ipcRenderer.on("startup:status", (_event, payload) => callback(payload)), - getStartupStatus: () => ipcRenderer.invoke("startup:get-status"), -}); + hasRequiredKey: () => ipcRenderer.invoke("api:has-required-key"), + telemetryStatus: () => ipcRenderer.invoke("api:telemetry-status"), + getTemplates: () => ipcRenderer.invoke("api:templates"), + onStartupStatus: (callback: (payload: { name: string; status: string }) => void) => + ipcRenderer.on("startup:status", (_event, payload) => callback(payload)), + getStartupStatus: () => ipcRenderer.invoke("startup:get-status"), +}); diff --git a/electron/servers/fastapi/api/v1/ppt/endpoints/presentation.py b/electron/servers/fastapi/api/v1/ppt/endpoints/presentation.py index ddd5ee5d..a4d8d23b 100644 --- a/electron/servers/fastapi/api/v1/ppt/endpoints/presentation.py +++ b/electron/servers/fastapi/api/v1/ppt/endpoints/presentation.py @@ -23,7 +23,6 @@ from models.presentation_outline_model import ( ) from enums.tone import Tone from enums.verbosity import Verbosity -from models.pptx_models import PptxPresentationModel from models.presentation_structure_model import PresentationStructureModel from models.presentation_with_slides import ( PresentationWithSlides, @@ -40,14 +39,12 @@ from models.sql.presentation_layout_code import PresentationLayoutCodeModel from models.sse_response import SSECompleteResponse, SSEErrorResponse, SSEResponse from services.database import get_async_session -from services.temp_file_service import TEMP_FILE_SERVICE from services.concurrent_service import CONCURRENT_SERVICE from models.sql.presentation import PresentationModel -from services.pptx_presentation_creator import PptxPresentationCreator from models.sql.async_presentation_generation_status import ( AsyncPresentationGenerationTaskModel, ) -from utils.asset_directory_utils import get_exports_directory, get_images_directory +from utils.asset_directory_utils import get_images_directory from utils.llm_calls.generate_presentation_structure import ( generate_presentation_structure, ) @@ -489,56 +486,6 @@ async def update_presentation( slides=response_slides, fonts=fonts, ) - - -@PRESENTATION_ROUTER.post("/export/pptx", response_model=str) -async def export_presentation_as_pptx( - pptx_model: Annotated[PptxPresentationModel, Body()], -): - temp_dir = TEMP_FILE_SERVICE.create_temp_dir() - - pptx_creator = PptxPresentationCreator(pptx_model, temp_dir) - await pptx_creator.create_ppt() - - export_directory = get_exports_directory() - pptx_path = os.path.join( - export_directory, f"{pptx_model.name or uuid.uuid4()}.pptx" - ) - pptx_creator.save(pptx_path) - - return pptx_path - - -@PRESENTATION_ROUTER.post("/export", response_model=PresentationPathAndEditPath) -async def export_presentation_as_pptx_or_pdf( - id: Annotated[uuid.UUID, Body(description="Presentation ID to export")], - export_as: Annotated[ - Literal["pptx", "pdf"], Body(description="Format to export the presentation as") - ] = "pptx", - sql_session: AsyncSession = Depends(get_async_session), -): - """ - Export a presentation as PPTX or PDF. - This Api is used to export via the nextjs app i.e using the puppeteer to export the presentation. - - """ - presentation = await sql_session.get(PresentationModel, id) - - if not presentation: - raise HTTPException(status_code=404, detail="Presentation not found") - - presentation_and_path = await export_presentation( - id, - presentation.title or str(uuid.uuid4()), - export_as, - ) - - return PresentationPathAndEditPath( - **presentation_and_path.model_dump(), - edit_path=f"/presentation?id={id}", - ) - - async def check_if_api_request_is_valid( request: GeneratePresentationRequest, sql_session: AsyncSession = Depends(get_async_session), diff --git a/electron/servers/fastapi/models/pptx_models.py b/electron/servers/fastapi/models/pptx_models.py deleted file mode 100644 index 84ef55d3..00000000 --- a/electron/servers/fastapi/models/pptx_models.py +++ /dev/null @@ -1,198 +0,0 @@ -from enum import Enum -from typing import Annotated, List, Literal, Optional, Union -from annotated_types import Len -from pydantic import BaseModel, Discriminator, field_validator -from pptx.util import Pt -from pptx.enum.text import PP_ALIGN -from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE, MSO_CONNECTOR_TYPE - - -class PptxBoxShapeEnum(Enum): - RECTANGLE = "rectangle" - CIRCLE = "circle" - - -class PptxObjectFitEnum(Enum): - CONTAIN = "contain" - COVER = "cover" - FILL = "fill" - - -class PptxSpacingModel(BaseModel): - top: int = 0 - bottom: int = 0 - left: int = 0 - right: int = 0 - - @classmethod - def all(cls, num: int): - return PptxSpacingModel(top=num, left=num, bottom=num, right=num) - - -class PptxPositionModel(BaseModel): - left: int = 0 - top: int = 0 - width: int = 0 - height: int = 0 - - @classmethod - def for_textbox(cls, left: int, top: int, width: int): - return cls(left=left, top=top, width=width, height=100) - - def to_pt_list(self) -> List[int]: - return [Pt(self.left), Pt(self.top), Pt(self.width), Pt(self.height)] - - def to_pt_xyxy(self) -> List[int]: - return [ - Pt(self.left), - Pt(self.top), - Pt(self.left + self.width), - Pt(self.top + self.height), - ] - - -class PptxFontModel(BaseModel): - name: str = "Inter" - size: int = 16 - italic: bool = False - color: str = "000000" - font_weight: Optional[int] = 400 - underline: Optional[bool] = None - strike: Optional[bool] = None - - -class PptxFillModel(BaseModel): - color: str - opacity: float = 1.0 - - -class PptxStrokeModel(BaseModel): - color: str - thickness: float - opacity: float = 1.0 - - -class PptxShadowModel(BaseModel): - radius: int - offset: int = 0 - color: str = "000000" - opacity: float = 0.5 - angle: int = 0 - - -class PptxTextRunModel(BaseModel): - text: str - font: Optional[PptxFontModel] = None - - -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 - - -class PptxObjectFitModel(BaseModel): - fit: Optional[PptxObjectFitEnum] = None - focus: Optional[ - Annotated[List[Optional[float]], Len(min_length=2, max_length=2)] - ] = None - - -class PptxPictureModel(BaseModel): - is_network: bool - path: str - - -class PptxShapeModel(BaseModel): - shape_type: Literal["textbox", "autoshape", "picture", "connector"] - - -class PptxTextBoxModel(PptxShapeModel): - shape_type: Literal["textbox"] = "textbox" - margin: Optional[PptxSpacingModel] = None - fill: Optional[PptxFillModel] = None - position: PptxPositionModel - text_wrap: bool = True - paragraphs: List[PptxParagraphModel] - - -class PptxAutoShapeBoxModel(PptxShapeModel): - shape_type: Literal["autoshape"] = "autoshape" - type: MSO_AUTO_SHAPE_TYPE = MSO_AUTO_SHAPE_TYPE.RECTANGLE - margin: Optional[PptxSpacingModel] = None - fill: Optional[PptxFillModel] = None - stroke: Optional[PptxStrokeModel] = None - shadow: Optional[PptxShadowModel] = None - position: PptxPositionModel - text_wrap: bool = True - border_radius: Optional[int] = None - paragraphs: Optional[List[PptxParagraphModel]] = None - - @field_validator('border_radius', mode='before') - @classmethod - def convert_border_radius_to_int(cls, v): - """Convert float border_radius values to int.""" - if v is None: - return None - if isinstance(v, float): - return int(round(v)) - return v - - -class PptxPictureBoxModel(PptxShapeModel): - shape_type: Literal["picture"] = "picture" - position: PptxPositionModel - margin: Optional[PptxSpacingModel] = None - clip: bool = True - opacity: Optional[float] = None - invert: bool = False - border_radius: Optional[List[int]] = None - shape: Optional[PptxBoxShapeEnum] = None - object_fit: Optional[PptxObjectFitModel] = None - picture: PptxPictureModel - - @field_validator('border_radius', mode='before') - @classmethod - def convert_border_radius_list_to_int(cls, v): - """Convert float values in border_radius list to int.""" - if v is None: - return None - if isinstance(v, list): - return [int(round(item)) if isinstance(item, float) else int(item) for item in v] - return v - - -class PptxConnectorModel(PptxShapeModel): - shape_type: Literal["connector"] = "connector" - type: MSO_CONNECTOR_TYPE = MSO_CONNECTOR_TYPE.STRAIGHT - position: PptxPositionModel - thickness: float = 0.5 - color: str = "000000" - opacity: float = 1.0 - - -# Define a discriminated union for shapes -PptxShapeUnion = Annotated[ - Union[ - PptxTextBoxModel, - PptxAutoShapeBoxModel, - PptxConnectorModel, - PptxPictureBoxModel, - ], - Discriminator("shape_type"), -] - - -class PptxSlideModel(BaseModel): - background: Optional[PptxFillModel] = None - note: Optional[str] = None - shapes: List[PptxShapeUnion] - - -class PptxPresentationModel(BaseModel): - name: Optional[str] = None - shapes: Optional[List[PptxShapeModel]] = None - slides: List[PptxSlideModel] diff --git a/electron/servers/fastapi/services/export_task_service.py b/electron/servers/fastapi/services/export_task_service.py index dccba7ab..97336d9c 100644 --- a/electron/servers/fastapi/services/export_task_service.py +++ b/electron/servers/fastapi/services/export_task_service.py @@ -4,7 +4,7 @@ import os import shutil import subprocess import tempfile -from typing import Mapping +from typing import Literal, Mapping from fastapi import HTTPException from pydantic import BaseModel @@ -27,6 +27,10 @@ class PptxToHtmlDocument(BaseModel): fonts_dir: str +class PresentationExportTaskResult(BaseModel): + path: str + + class ExportTaskService: def __init__(self, timeout_seconds: int = 300): self.timeout_seconds = timeout_seconds @@ -154,29 +158,24 @@ class ExportTaskService: detail="PPTX-to-HTML task completed without a valid output path", ) - async def convert_pptx_to_html( - self, pptx_path: str, get_fonts: bool = False - ) -> PptxToHtmlDocument: - self._ensure_runtime_ready() - if not os.path.isfile(pptx_path): - raise HTTPException(status_code=400, detail=f"PPTX not found: {pptx_path}") - - temp_root = get_temp_directory_env() or os.path.join(tempfile.gettempdir(), "presenton") + @staticmethod + def _create_task_paths() -> tuple[str, str, str]: + temp_root = get_temp_directory_env() or os.path.join( + tempfile.gettempdir(), "presenton" + ) os.makedirs(temp_root, exist_ok=True) temp_dir = tempfile.mkdtemp(prefix="export-task-", dir=temp_root) task_path = os.path.join(temp_dir, "export_task.json") response_path = os.path.join(temp_dir, "export_task.response.json") + return temp_dir, task_path, response_path + + async def _run_task(self, task_payload: dict, response_error_detail: str) -> dict: + self._ensure_runtime_ready() + temp_dir, task_path, response_path = self._create_task_paths() try: with open(task_path, "w", encoding="utf-8") as task_file: - json.dump( - { - "type": "pptx-to-html", - "pptx_path": pptx_path, - "get_fonts": get_fonts, - }, - task_file, - ) + json.dump(task_payload, task_file) result = await asyncio.to_thread( subprocess.run, @@ -192,7 +191,7 @@ class ExportTaskService: raise HTTPException( status_code=500, detail=( - "PPTX-to-HTML export task failed. " + "Export task failed. " f"stderr={_snippet(result.stderr)} stdout={_snippet(result.stdout)}" ), ) @@ -200,34 +199,77 @@ class ExportTaskService: if not os.path.isfile(response_path): raise HTTPException( status_code=500, - detail="PPTX-to-HTML export task did not produce a response file", + detail=response_error_detail, ) with open(response_path, "r", encoding="utf-8") as response_file: - response_data = json.load(response_file) + return json.load(response_file) + except subprocess.TimeoutExpired as exc: + raise HTTPException( + status_code=500, + detail=f"Export task timed out after {self.timeout_seconds} seconds", + ) from exc + except json.JSONDecodeError as exc: + raise HTTPException( + status_code=500, + detail="Export task produced invalid JSON output", + ) from exc + except OSError as exc: + raise HTTPException( + status_code=500, + detail=f"Failed to run export task: {exc}", + ) from exc + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + async def export_from_url( + self, + url: str, + title: str, + export_as: Literal["pdf", "pptx"], + fastapi_url: str | None = None, + ) -> PresentationExportTaskResult: + response_data = await self._run_task( + { + "type": "export", + "url": url, + "format": export_as, + "title": title, + "fastapiUrl": fastapi_url or None, + }, + "Export task did not produce a response file", + ) + + return PresentationExportTaskResult( + path=self._resolve_output_path(response_data), + ) + + async def convert_pptx_to_html( + self, pptx_path: str, get_fonts: bool = False + ) -> PptxToHtmlDocument: + if not os.path.isfile(pptx_path): + raise HTTPException(status_code=400, detail=f"PPTX not found: {pptx_path}") + + try: + response_data = await self._run_task( + { + "type": "pptx-to-html", + "pptx_path": pptx_path, + "get_fonts": get_fonts, + }, + "PPTX-to-HTML export task did not produce a response file", + ) output_path = self._resolve_output_path(response_data) with open(output_path, "r", encoding="utf-8") as output_file: output_data = json.load(output_file) return PptxToHtmlDocument(**output_data) - except subprocess.TimeoutExpired as exc: - raise HTTPException( - status_code=500, - detail=f"PPTX-to-HTML export timed out after {self.timeout_seconds} seconds", - ) from exc except json.JSONDecodeError as exc: raise HTTPException( status_code=500, detail="PPTX-to-HTML export produced invalid JSON output", ) from exc - except OSError as exc: - raise HTTPException( - status_code=500, - detail=f"Failed to run PPTX-to-HTML export task: {exc}", - ) from exc - finally: - shutil.rmtree(temp_dir, ignore_errors=True) def sys_platform() -> str: diff --git a/electron/servers/fastapi/services/html_to_text_runs_service.py b/electron/servers/fastapi/services/html_to_text_runs_service.py deleted file mode 100644 index 25a441a7..00000000 --- a/electron/servers/fastapi/services/html_to_text_runs_service.py +++ /dev/null @@ -1,65 +0,0 @@ -from html.parser import HTMLParser -from typing import List, Optional - -from models.pptx_models import PptxFontModel, PptxTextRunModel - - -class InlineHTMLToRunsParser(HTMLParser): - def __init__(self, base_font: PptxFontModel): - super().__init__(convert_charrefs=True) - self.base_font = base_font - self.tag_stack: List[str] = [] - self.text_runs: List[PptxTextRunModel] = [] - - def _current_font(self) -> PptxFontModel: - font_json = self.base_font.model_dump() - is_bold = any(tag in ("strong", "b") for tag in self.tag_stack) - is_italic = any(tag in ("em", "i") for tag in self.tag_stack) - is_underline = any(tag == "u" for tag in self.tag_stack) - is_strike = any(tag in ("s", "strike", "del") for tag in self.tag_stack) - is_code = any(tag == "code" for tag in self.tag_stack) - - if is_bold: - font_json["font_weight"] = 700 - if is_italic: - font_json["italic"] = True - if is_underline: - font_json["underline"] = True - if is_strike: - font_json["strike"] = True - if is_code: - font_json["name"] = "Courier New" - - return PptxFontModel(**font_json) - - def handle_starttag(self, tag, attrs): - tag = tag.lower() - if tag == "br": - self.text_runs.append(PptxTextRunModel(text="\n")) - return - self.tag_stack.append(tag) - - def handle_endtag(self, tag): - tag = tag.lower() - for i in range(len(self.tag_stack) - 1, -1, -1): - if self.tag_stack[i] == tag: - del self.tag_stack[i] - break - - def handle_data(self, data): - if data == "": - return - self.text_runs.append(PptxTextRunModel(text=data, font=self._current_font())) - - -def parse_html_text_to_text_runs( - text: str, base_font: Optional[PptxFontModel] = None -) -> List[PptxTextRunModel]: - normalized_text = text.replace("\r\n", "\n").replace("\r", "\n") - normalized_text = normalized_text.replace("\n", "
") - - parser = InlineHTMLToRunsParser(base_font if base_font else PptxFontModel()) - parser.feed(normalized_text) - return parser.text_runs - - diff --git a/electron/servers/fastapi/services/pptx_presentation_creator.py b/electron/servers/fastapi/services/pptx_presentation_creator.py deleted file mode 100644 index d3e51a7f..00000000 --- a/electron/servers/fastapi/services/pptx_presentation_creator.py +++ /dev/null @@ -1,632 +0,0 @@ -import os -from typing import List, Optional -from lxml import etree -from services.html_to_text_runs_service import ( - parse_html_text_to_text_runs as parse_inline_html_to_runs, -) -import tempfile -import zipfile - -from pptx import Presentation -from pptx.shapes.autoshape import Shape -from pptx.slide import Slide -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 - -from models.pptx_models import ( - PptxAutoShapeBoxModel, - PptxBoxShapeEnum, - PptxConnectorModel, - PptxFillModel, - PptxFontModel, - PptxParagraphModel, - PptxPictureBoxModel, - PptxPositionModel, - PptxPresentationModel, - PptxShadowModel, - PptxSlideModel, - PptxSpacingModel, - PptxStrokeModel, - PptxTextBoxModel, - PptxTextRunModel, -) -from utils.asset_directory_utils import get_images_directory, resolve_image_path_to_filesystem -from utils.download_helpers import download_files -from utils.get_env import get_app_data_directory_env -from utils.image_utils import ( - clip_image, - create_circle_image, - fit_image, - invert_image, - round_image_corners, - set_image_opacity, -) -import uuid - -BLANK_SLIDE_LAYOUT = 6 - - -class PptxPresentationCreator: - def __init__(self, ppt_model: PptxPresentationModel, temp_dir: str): - self._temp_dir = temp_dir - - self._ppt_model = ppt_model - self._slide_models = ppt_model.slides - - self._ppt = Presentation() - 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 - - - def fix_keynote_compatibility(self, pptx_path: str): - """Patch pptx XML for stricter parsers like Keynote.""" - PRESENTATION_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" - DRAWING_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" - REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" - NOTES_MASTER_REL_TYPE = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" - ) - - def ensure_grp_sppr_xfrm(slide_path: str): - slide_tree = etree.parse(slide_path) - slide_root = slide_tree.getroot() - grp_sppr_elements = slide_root.findall( - f".//{{{PRESENTATION_NS}}}grpSpPr" - ) - changed = False - for grp_sppr in grp_sppr_elements: - xfrm = grp_sppr.find(f"{{{DRAWING_NS}}}xfrm") - if xfrm is None: - xfrm = etree.SubElement(grp_sppr, f"{{{DRAWING_NS}}}xfrm") - etree.SubElement(xfrm, f"{{{DRAWING_NS}}}off", x="0", y="0") - etree.SubElement(xfrm, f"{{{DRAWING_NS}}}ext", cx="0", cy="0") - etree.SubElement(xfrm, f"{{{DRAWING_NS}}}chOff", x="0", y="0") - etree.SubElement(xfrm, f"{{{DRAWING_NS}}}chExt", cx="0", cy="0") - changed = True - if changed: - slide_tree.write( - slide_path, - xml_declaration=True, - encoding="UTF-8", - standalone="yes", - ) - - with tempfile.TemporaryDirectory() as temp_dir: - extract_dir = os.path.join(temp_dir, "pptx_contents") - os.makedirs(extract_dir, exist_ok=True) - with zipfile.ZipFile(pptx_path, "r") as existing_zip: - existing_zip.extractall(extract_dir) - - ppt_dir = os.path.join(extract_dir, "ppt") - slides_dir = os.path.join(ppt_dir, "slides") - if os.path.isdir(slides_dir): - for file_name in os.listdir(slides_dir): - if file_name.endswith(".xml"): - ensure_grp_sppr_xfrm(os.path.join(slides_dir, file_name)) - - rels_path = os.path.join(ppt_dir, "_rels", "presentation.xml.rels") - presentation_path = os.path.join(ppt_dir, "presentation.xml") - if os.path.exists(rels_path) and os.path.exists(presentation_path): - rels_tree = etree.parse(rels_path) - rels_root = rels_tree.getroot() - rel_tag = f"{{{PACKAGE_REL_NS}}}Relationship" - notes_master_rel = None - existing_ids = set() - for rel in rels_root.findall(rel_tag): - rel_id = rel.get("Id") - if rel_id: - existing_ids.add(rel_id) - if rel.get("Type") == NOTES_MASTER_REL_TYPE: - notes_master_rel = rel - - notes_masters_dir = os.path.join(ppt_dir, "notesMasters") - has_notes_master = ( - os.path.isdir(notes_masters_dir) - and any( - name.endswith(".xml") for name in os.listdir(notes_masters_dir) - ) - ) - - if has_notes_master and notes_master_rel is None: - next_id = 1 - while f"rId{next_id}" in existing_ids: - next_id += 1 - notes_master_rel = etree.SubElement(rels_root, rel_tag) - notes_master_rel.set("Id", f"rId{next_id}") - notes_master_rel.set("Type", NOTES_MASTER_REL_TYPE) - notes_master_rel.set( - "Target", "notesMasters/notesMaster1.xml" - ) - rels_tree.write( - rels_path, - xml_declaration=True, - encoding="UTF-8", - standalone="yes", - ) - - if has_notes_master and notes_master_rel is not None: - presentation_tree = etree.parse(presentation_path) - presentation_root = presentation_tree.getroot() - notes_master_id_lst = presentation_root.find( - f"{{{PRESENTATION_NS}}}notesMasterIdLst" - ) - if notes_master_id_lst is None: - notes_master_id_lst = etree.Element( - f"{{{PRESENTATION_NS}}}notesMasterIdLst" - ) - sld_master_id_lst = presentation_root.find( - f"{{{PRESENTATION_NS}}}sldMasterIdLst" - ) - if sld_master_id_lst is not None: - insert_index = list(presentation_root).index( - sld_master_id_lst - ) + 1 - presentation_root.insert(insert_index, notes_master_id_lst) - else: - presentation_root.insert(0, notes_master_id_lst) - if not notes_master_id_lst.findall( - f"{{{PRESENTATION_NS}}}notesMasterId" - ): - notes_master_id = etree.SubElement( - notes_master_id_lst, - f"{{{PRESENTATION_NS}}}notesMasterId", - ) - notes_master_id.set( - f"{{{REL_NS}}}id", - notes_master_rel.get("Id"), - ) - presentation_tree.write( - presentation_path, - xml_declaration=True, - encoding="UTF-8", - standalone="yes", - ) - - with zipfile.ZipFile(pptx_path, "w", zipfile.ZIP_DEFLATED) as new_zip: - for root, _, files in os.walk(extract_dir): - for file_name in files: - full_path = os.path.join(root, file_name) - archive_name = os.path.relpath(full_path, extract_dir) - new_zip.write(full_path, archive_name) - - - async def fetch_network_assets(self): - image_urls = [] - models_with_network_asset: List[PptxPictureBoxModel] = [] - - def _process_image_path(each_shape, image_path): - if not image_path.startswith("http"): - return - if "app_data/" in image_path: - relative_path = image_path.split("app_data/")[1] - app_data_dir = get_app_data_directory_env() - if app_data_dir: - each_shape.picture.path = os.path.join(app_data_dir, relative_path) - else: - each_shape.picture.path = os.path.join("/app_data", relative_path) - each_shape.picture.is_network = False - return - # Resolve HTTP URLs that contain absolute filesystem paths (Mac/Electron) - local_path = resolve_image_path_to_filesystem(image_path) - if local_path: - each_shape.picture.path = local_path - each_shape.picture.is_network = False - return - image_urls.append(image_path) - models_with_network_asset.append(each_shape) - - if self._ppt_model.shapes: - for each_shape in self._ppt_model.shapes: - if isinstance(each_shape, PptxPictureBoxModel): - _process_image_path(each_shape, each_shape.picture.path) - - for each_slide in self._slide_models: - for each_shape in each_slide.shapes: - if isinstance(each_shape, PptxPictureBoxModel): - _process_image_path(each_shape, each_shape.picture.path) - - if image_urls: - image_paths = await download_files(image_urls, self._temp_dir) - - for each_shape, each_image_path in zip( - models_with_network_asset, image_paths - ): - if each_image_path: - each_shape.picture.path = each_image_path - each_shape.picture.is_network = False - - async def create_ppt(self): - await self.fetch_network_assets() - - for slide_model in self._slide_models: - # Adding global shapes to slide - if self._ppt_model.shapes: - slide_model.shapes.append(self._ppt_model.shapes) - - self.add_and_populate_slide(slide_model) - - def set_presentation_theme(self): - slide_master = self._ppt.slide_master - slide_master_part = slide_master.part - - theme_part = slide_master_part.part_related_by(RT.THEME) - theme = fromstring(theme_part.blob) - - theme_colors = self._theme.colors.theme_color_mapping - nsmap = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"} - - for color_name, hex_value in theme_colors.items(): - if color_name: - color_element = theme.xpath( - f"a:themeElements/a:clrScheme/a:{color_name}/a:srgbClr", - namespaces=nsmap, - )[0] - color_element.set("val", hex_value.encode("utf-8")) - - theme_part._blob = tostring(theme) - - def add_and_populate_slide(self, slide_model: PptxSlideModel): - slide = self._ppt.slides.add_slide(self._ppt.slide_layouts[BLANK_SLIDE_LAYOUT]) - - if slide_model.background: - self.apply_fill_to_shape(slide.background, slide_model.background) - - if slide_model.note: - slide.notes_slide.notes_text_frame.text = slide_model.note - - for shape_model in slide_model.shapes: - model_type = type(shape_model) - - if model_type is PptxPictureBoxModel: - self.add_picture(slide, shape_model) - - elif model_type is PptxAutoShapeBoxModel: - self.add_autoshape(slide, shape_model) - - elif model_type is PptxTextBoxModel: - self.add_textbox(slide, shape_model) - - elif model_type is PptxConnectorModel: - self.add_connector(slide, shape_model) - - def add_connector(self, slide: Slide, connector_model: PptxConnectorModel): - if connector_model.thickness == 0: - return - connector_shape = slide.shapes.add_connector( - connector_model.type, *connector_model.position.to_pt_xyxy() - ) - connector_shape.line.width = Pt(connector_model.thickness) - connector_shape.line.color.rgb = RGBColor.from_string(connector_model.color) - self.set_fill_opacity(connector_shape, connector_model.opacity) - - def add_picture(self, slide: Slide, picture_model: PptxPictureBoxModel): - image_path = picture_model.picture.path - # Resolve /app_data/... to actual filesystem path (Electron) - if image_path.startswith("/app_data/"): - app_data_dir = get_app_data_directory_env() - if app_data_dir: - relative = image_path[len("/app_data/"):] - image_path = os.path.join(app_data_dir, relative) - if ( - picture_model.clip - or picture_model.border_radius - or picture_model.invert - or picture_model.opacity - or picture_model.object_fit - or picture_model.shape - ): - try: - image = Image.open(image_path) - except Exception: - print(f"Could not open image: {image_path}") - return - - image = image.convert("RGBA") - # ? Applying border radius twice to support both clip and object fit - if picture_model.border_radius: - image = round_image_corners(image, picture_model.border_radius) - if picture_model.object_fit: - image = fit_image( - image, - picture_model.position.width, - picture_model.position.height, - picture_model.object_fit, - ) - elif picture_model.clip: - image = clip_image( - image, - picture_model.position.width, - picture_model.position.height, - ) - if picture_model.border_radius: - image = round_image_corners(image, picture_model.border_radius) - if picture_model.shape == PptxBoxShapeEnum.CIRCLE: - image = create_circle_image(image) - if picture_model.invert: - image = invert_image(image) - if picture_model.opacity: - image = set_image_opacity(image, picture_model.opacity) - image_path = os.path.join(self._temp_dir, f"{uuid.uuid4()}.png") - image.save(image_path) - - margined_position = self.get_margined_position( - picture_model.position, picture_model.margin - ) - - slide.shapes.add_picture(image_path, *margined_position.to_pt_list()) - - def add_autoshape(self, slide: Slide, autoshape_box_model: PptxAutoShapeBoxModel): - position = autoshape_box_model.position - if autoshape_box_model.margin: - position = self.get_margined_position(position, autoshape_box_model.margin) - - autoshape = slide.shapes.add_shape( - autoshape_box_model.type, *position.to_pt_list() - ) - - textbox = autoshape.text_frame - textbox.word_wrap = autoshape_box_model.text_wrap - - self.apply_fill_to_shape(autoshape, autoshape_box_model.fill) - self.apply_margin_to_text_box(textbox, autoshape_box_model.margin) - self.apply_stroke_to_shape(autoshape, autoshape_box_model.stroke) - self.apply_shadow_to_shape(autoshape, autoshape_box_model.shadow) - self.apply_border_radius_to_shape(autoshape, autoshape_box_model.border_radius) - - if autoshape_box_model.paragraphs: - self.add_paragraphs(textbox, autoshape_box_model.paragraphs) - - def add_textbox(self, slide: Slide, textbox_model: PptxTextBoxModel): - position = textbox_model.position - textbox_shape = slide.shapes.add_textbox(*position.to_pt_list()) - textbox_shape.width += Pt(2) - - textbox = textbox_shape.text_frame - textbox.word_wrap = textbox_model.text_wrap - - self.apply_fill_to_shape(textbox_shape, textbox_model.fill) - self.apply_margin_to_text_box(textbox, textbox_model.margin) - self.add_paragraphs(textbox, textbox_model.paragraphs) - - def add_paragraphs( - self, textbox: TextFrame, paragraph_models: List[PptxParagraphModel] - ): - for index, paragraph_model in enumerate(paragraph_models): - paragraph = textbox.add_paragraph() if index > 0 else textbox.paragraphs[0] - self.populate_paragraph(paragraph, paragraph_model) - - def populate_paragraph( - self, paragraph: _Paragraph, paragraph_model: PptxParagraphModel - ): - 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 - - if paragraph_model.font: - self.apply_font_to_paragraph(paragraph, paragraph_model.font) - - text_runs = [] - if paragraph_model.text: - text_runs = self.parse_html_text_to_text_runs( - paragraph_model.font, paragraph_model.text - ) - elif paragraph_model.text_runs: - text_runs = paragraph_model.text_runs - - for text_run_model in text_runs: - text_run = paragraph.add_run() - self.populate_text_run(text_run, text_run_model) - - def parse_html_text_to_text_runs(self, font: Optional[PptxFontModel], text: str): - return parse_inline_html_to_runs(text, font) - - def populate_text_run(self, text_run: _Run, text_run_model: PptxTextRunModel): - text_run.text = text_run_model.text - if text_run_model.font: - self.apply_font(text_run.font, text_run_model.font) - - def apply_border_radius_to_shape(self, shape: Shape, border_radius: Optional[int]): - if not border_radius: - return - try: - normalized_border_radius = Pt(border_radius) / min( - shape.width, shape.height - ) - shape.adjustments[0] = normalized_border_radius - except Exception: - print("Could not apply border radius.") - - def apply_fill_to_shape(self, shape: Shape, fill: Optional[PptxFillModel] = None): - if not fill: - shape.fill.background() - 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 - ): - if not stroke or stroke.thickness == 0: - shape.line.fill.background() - else: - 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 - ): - # Access the XML for the shape - sp_element = shape._element - sp_pr = sp_element.xpath("p:spPr")[0] # Shape properties XML element - - nsmap = sp_pr.nsmap - - # # Remove existing shadow effects if present - effect_list = sp_pr.find("a:effectLst", namespaces=nsmap) - if effect_list: - old_outer_shadow = effect_list.find("a:outerShdw") - if old_outer_shadow: - effect_list.remove( - 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 effect_list: - effect_list = etree.SubElement( - sp_pr, f"{{{nsmap['a']}}}effectLst", 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 - # dir expects 60000ths of a degree in OOXML - angle_dir = ( - int(round((shadow.angle % 360) * 60000)) - if shadow.angle is not None - else 0 - ) - outer_shadow = etree.SubElement( - effect_list, - f"{{{nsmap['a']}}}outerShdw", - { - "blurRad": f"{Pt(shadow.radius)}", - "dir": f"{angle_dir}", - "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: - 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: - if not margin: - return position - - left = position.left + margin.left - top = position.top + margin.top - width = max(position.width - margin.left - margin.right, 0) - height = max(position.height - margin.top - margin.bottom, 0) - - return PptxPositionModel(left=left, top=top, width=width, height=height) - - def apply_margin_to_text_box( - self, text_frame: TextFrame, margin: Optional[PptxSpacingModel] - ) -> PptxPositionModel: - text_frame.margin_left = Pt(margin.left if margin else 0) - text_frame.margin_right = Pt(margin.right if margin else 0) - text_frame.margin_top = Pt(margin.top if margin else 0) - text_frame.margin_bottom = Pt(margin.bottom if margin else 0) - - def apply_spacing_to_paragraph( - self, paragraph: _Paragraph, spacing: PptxSpacingModel - ): - paragraph.space_before = Pt(spacing.top) - paragraph.space_after = Pt(spacing.bottom) - - def apply_font_to_paragraph(self, paragraph: _Paragraph, font: PptxFontModel): - self.apply_font(paragraph.font, font) - - def apply_font(self, font: Font, font_model: PptxFontModel): - font.name = font_model.name - font.color.rgb = RGBColor.from_string(font_model.color) - font.italic = font_model.italic - font.size = Pt(font_model.size) - font.bold = font_model.font_weight >= 600 - if font_model.underline is not None: - font.underline = bool(font_model.underline) - if font_model.strike is not None: - self.apply_strike_to_font(font, font_model.strike) - - def apply_strike_to_font(self, font: Font, strike: Optional[bool]): - try: - rPr = font._element - if strike is True: - rPr.set("strike", "sngStrike") - elif strike is False: - rPr.set("strike", "noStrike") - except Exception as e: - print(f"Could not apply strikethrough: {e}") - - def save(self, path: str): - self._ppt.save(path) - self.fix_keynote_compatibility(path) diff --git a/electron/servers/fastapi/tests/test_pptx_creator.py b/electron/servers/fastapi/tests/test_pptx_creator.py deleted file mode 100644 index 9cc92718..00000000 --- a/electron/servers/fastapi/tests/test_pptx_creator.py +++ /dev/null @@ -1,40 +0,0 @@ -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") diff --git a/electron/servers/fastapi/utils/export_utils.py b/electron/servers/fastapi/utils/export_utils.py index 597212ca..c75d8b42 100644 --- a/electron/servers/fastapi/utils/export_utils.py +++ b/electron/servers/fastapi/utils/export_utils.py @@ -1,67 +1,47 @@ -import json import os -import aiohttp from typing import Literal +from urllib.parse import urlencode import uuid -from fastapi import HTTPException + from pathvalidate import sanitize_filename -from models.pptx_models import PptxPresentationModel from models.presentation_and_path import PresentationAndPath -from services.pptx_presentation_creator import PptxPresentationCreator -from services.temp_file_service import TEMP_FILE_SERVICE -from utils.asset_directory_utils import get_exports_directory -import uuid +from services.export_task_service import EXPORT_TASK_SERVICE + + +def _get_next_public_url() -> str: + return (os.getenv("NEXT_PUBLIC_URL") or "").strip() or "http://127.0.0.1" + + +def _get_next_public_fastapi_url() -> str | None: + value = (os.getenv("NEXT_PUBLIC_FAST_API") or "").strip() + return value or None + + +def _build_presentation_export_url(presentation_id: uuid.UUID) -> tuple[str, str | None]: + params = {"id": str(presentation_id)} + fastapi_url = _get_next_public_fastapi_url() + if fastapi_url: + params["fastapiUrl"] = fastapi_url + + return ( + f"{_get_next_public_url().rstrip('/')}/pdf-maker?{urlencode(params)}", + fastapi_url, + ) async def export_presentation( presentation_id: uuid.UUID, title: str, export_as: Literal["pptx", "pdf"] ) -> PresentationAndPath: - if export_as == "pptx": + export_url, fastapi_url = _build_presentation_export_url(presentation_id) + export_result = await EXPORT_TASK_SERVICE.export_from_url( + url=export_url, + title=sanitize_filename(title or str(uuid.uuid4())), + export_as=export_as, + fastapi_url=fastapi_url, + ) - # Get the converted PPTX model from the Next.js service - async with aiohttp.ClientSession() as session: - async with session.get( - f"http://localhost/api/presentation_to_pptx_model?id={presentation_id}" - ) as response: - if response.status != 200: - error_text = await response.text() - print(f"Failed to get PPTX model: {error_text}") - raise HTTPException( - status_code=500, - detail="Failed to convert presentation to PPTX model", - ) - pptx_model_data = await response.json() - - # Create PPTX file using the converted model - pptx_model = PptxPresentationModel(**pptx_model_data) - temp_dir = TEMP_FILE_SERVICE.create_temp_dir() - pptx_creator = PptxPresentationCreator(pptx_model, temp_dir) - await pptx_creator.create_ppt() - - export_directory = get_exports_directory() - pptx_path = os.path.join( - export_directory, - f"{sanitize_filename(title or str(uuid.uuid4()))}.pptx", - ) - pptx_creator.save(pptx_path) - - return PresentationAndPath( - presentation_id=presentation_id, - path=pptx_path, - ) - else: - async with aiohttp.ClientSession() as session: - async with session.post( - "http://localhost/api/export-as-pdf", - json={ - "id": str(presentation_id), - "title": sanitize_filename(title or str(uuid.uuid4())), - }, - ) as response: - response_json = await response.json() - - return PresentationAndPath( - presentation_id=presentation_id, - path=response_json["path"], - ) + return PresentationAndPath( + presentation_id=presentation_id, + path=export_result.path, + ) diff --git a/electron/servers/fastapi/utils/image_utils.py b/electron/servers/fastapi/utils/image_utils.py deleted file mode 100644 index 3e8ebae8..00000000 --- a/electron/servers/fastapi/utils/image_utils.py +++ /dev/null @@ -1,258 +0,0 @@ -from typing import List - -from PIL import Image, ImageDraw - -from models.pptx_models import PptxObjectFitEnum, PptxObjectFitModel - - -def clip_image( - image: Image.Image, - width: int, - height: int, - focus_x: float = 50.0, - focus_y: float = 50.0, -) -> Image.Image: - img_width, img_height = image.size - - img_aspect = img_width / img_height - box_aspect = width / height - - if img_aspect > box_aspect: - new_height = height - new_width = int(new_height * img_aspect) - else: - new_width = width - new_height = int(new_width / img_aspect) - - resized_image = image.resize((new_width, new_height), Image.LANCZOS) - - # Calculate clipping position based on focus - # Convert focus percentages (0-100) to position in the resized image - focus_x = max(0.0, min(100.0, focus_x)) # Clamp to 0-100 range - focus_y = max(0.0, min(100.0, focus_y)) # Clamp to 0-100 range - - # Calculate the center point based on focus - center_x = int((new_width - width) * (focus_x / 100.0)) - center_y = int((new_height - height) * (focus_y / 100.0)) - - # Calculate clipping box - left = center_x - top = center_y - right = left + width - bottom = top + height - - clipped_image = resized_image.crop((left, top, right, bottom)) - - return clipped_image - - -def round_image_corners(image: Image.Image, radii: List[int]) -> Image.Image: - if len(radii) != 4: - raise ValueError( - "Image Border Radius - radii must contain exactly 4 values for each corner" - ) - - w, h = image.size - - # Clamp border radius to not exceed half the width or height - max_radius = min(w // 2, h // 2) - clamped_radii = [min(radius, max_radius) for radius in radii] - - # Ensure the image has an alpha channel (RGBA) - if image.mode != "RGBA": - image = image.convert("RGBA") - - # Create a mask for the rounded corners (start with fully transparent) - rounded_mask = Image.new("L", image.size, 0) - - # Create a rectangular mask (fully opaque) - rectangular_mask = Image.new("L", image.size, 255) - - # Process each corner - for i, radius in enumerate(clamped_radii): - if radius > 0: # Only process if radius is positive - # Create a circle for this radius - circle = Image.new("L", (radius * 2, radius * 2), 0) - draw = ImageDraw.Draw(circle) - draw.ellipse((0, 0, radius * 2 - 1, radius * 2 - 1), fill=255) - - # Calculate position based on corner index - if i == 0: # top-left - rounded_mask.paste(circle.crop((0, 0, radius, radius)), (0, 0)) - rectangular_mask.paste(0, (0, 0, radius, radius)) - elif i == 1: # top-right - rounded_mask.paste( - circle.crop((radius, 0, radius * 2, radius)), (w - radius, 0) - ) - rectangular_mask.paste(0, (w - radius, 0, w, radius)) - elif i == 2: # bottom-right - rounded_mask.paste( - circle.crop((radius, radius, radius * 2, radius * 2)), - (w - radius, h - radius), - ) - rectangular_mask.paste(0, (w - radius, h - radius, w, h)) - else: # bottom-left - rounded_mask.paste( - circle.crop((0, radius, radius, radius * 2)), (0, h - radius) - ) - rectangular_mask.paste(0, (0, h - radius, radius, h)) - - # Get the original alpha channel - original_alpha = image.getchannel("A") - - # Combine the rectangular mask with the rounded corners - corner_mask = Image.composite(rounded_mask, rectangular_mask, rounded_mask) - - # Combine the corner mask with the original alpha channel - final_alpha = Image.composite( - original_alpha, Image.new("L", image.size, 0), corner_mask - ) - - # Create a new image with the modified alpha channel - result = Image.new("RGBA", image.size) - result.paste(image.convert("RGB"), (0, 0)) - result.putalpha(final_alpha) - - return result - - -def invert_image(img: Image.Image) -> Image.Image: - # Get image data - data = img.getdata() - - # Process each pixel - new_data = [] - for item in data: - # Get current pixel values - r, g, b, a = item - - # Invert RGB values while preserving transparency - if a != 0: # Skip fully transparent pixels - new_data.append((255 - r, 255 - g, 255 - b, a)) - else: - new_data.append((0, 0, 0, 0)) - - # Create new image with modified data - new_img = Image.new("RGBA", img.size) - new_img.putdata(new_data) - return new_img - - -def create_circle_image( - image: Image.Image, -) -> Image.Image: - # Convert to RGBA if not already - img = image.convert("RGBA") - # Get the original image size - size = img.size - # Use the smaller dimension for the circle - circle_size = min(size) - # Create a transparent image of the same size as original - mask = Image.new("RGBA", size, color=(0, 0, 0, 0)) - draw = ImageDraw.Draw(mask) - - # Calculate center position - center_x = size[0] // 2 - center_y = size[1] // 2 - radius = circle_size // 2 - - # Create a circular mask - draw.ellipse( - ( - center_x - radius, - center_y - radius, - center_x + radius, - center_y + radius, - ), - fill=(255, 255, 255, 255), - ) - - # Apply the circular mask - result = Image.composite(img, mask, mask) - return result - - -def set_image_opacity(image: Image.Image, opacity: float) -> Image.Image: - # Clamp opacity to valid range - opacity = max(0.0, min(1.0, opacity)) - - # Convert to RGBA if not already - if image.mode != "RGBA": - image = image.convert("RGBA") - - # Get the original alpha channel - original_alpha = image.getchannel("A") - - # Create new alpha channel with adjusted opacity - new_alpha = original_alpha.point(lambda x: int(x * opacity)) - - # Create new image with modified alpha channel - result = Image.new("RGBA", image.size) - result.paste(image.convert("RGB"), (0, 0)) - result.putalpha(new_alpha) - - return result - - -def fit_image( - image: Image.Image, width: int, height: int, object_fit: PptxObjectFitModel -) -> Image.Image: - if not object_fit.fit: - return image - - img_width, img_height = image.size - img_aspect = img_width / img_height - box_aspect = width / height - - if object_fit.fit == PptxObjectFitEnum.CONTAIN: - # Scale image to fit within the box while maintaining aspect ratio - if img_aspect > box_aspect: - new_width = width - new_height = int(width / img_aspect) - else: - new_height = height - new_width = int(height * img_aspect) - resized_image = image.resize((new_width, new_height), Image.LANCZOS) - - # Use focus point for positioning if available - focus_x = 50.0 - focus_y = 50.0 - if object_fit.focus and len(object_fit.focus) == 2: - focus_x, focus_y = object_fit.focus[0], object_fit.focus[1] - - # Calculate paste position based on focus - paste_x = int((width - new_width) * (focus_x / 100.0)) - paste_y = int((height - new_height) * (focus_y / 100.0)) - - result = Image.new("RGBA", (width, height), (0, 0, 0, 0)) - result.paste(resized_image, (paste_x, paste_y)) - return result - - elif object_fit.fit == PptxObjectFitEnum.COVER: - # Scale image to cover the box while maintaining aspect ratio - if img_aspect > box_aspect: - new_height = height - new_width = int(height * img_aspect) - else: - new_width = width - new_height = int(width / img_aspect) - resized_image = image.resize((new_width, new_height), Image.LANCZOS) - - # Use focus point for positioning if available - focus_x = 50.0 - focus_y = 50.0 - if object_fit.focus and len(object_fit.focus) == 2: - focus_x, focus_y = object_fit.focus[0], object_fit.focus[1] - - # Calculate paste position based on focus - paste_x = int((new_width - width) * (focus_x / 100.0)) - paste_y = int((new_height - height) * (focus_y / 100.0)) - - # Clip the image to the box size - return resized_image.crop((paste_x, paste_y, paste_x + width, paste_y + height)) - - elif object_fit.fit == PptxObjectFitEnum.FILL: - # Stretch image to fill the box exactly - return image.resize((width, height), Image.LANCZOS) - - return image diff --git a/electron/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx b/electron/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx index 3a9687c3..35b946e1 100644 --- a/electron/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx +++ b/electron/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx @@ -25,7 +25,6 @@ import { useDispatch, useSelector } from "react-redux"; import { RootState } from "@/store/store"; import { toast } from "sonner"; -import { PptxPresentationModel } from "@/types/pptx_models"; import { trackEvent, MixpanelEvent } from "@/utils/mixpanel"; import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo"; import ToolTip from "@/components/ToolTip"; @@ -42,6 +41,39 @@ import { Theme } from "../../services/api/types"; import MarkdownRenderer from "@/components/MarkDownRender"; import { cn } from "@/lib/utils"; +const MAX_EXPORT_TITLE_LENGTH = 40; + +const buildSafeExportFileName = ( + rawTitle: string | null | undefined, + extension: "pdf" | "pptx" +) => { + const normalizedTitle = (rawTitle || "presentation").trim(); + const titleWithoutExtension = normalizedTitle.replace( + /\.(pdf|pptx)$/i, + "" + ); + + let safeBase = titleWithoutExtension + .replace(/[^a-zA-Z0-9\s_-]+/g, "-") + .replace(/\s+/g, "-") + .replace(/[-_]{2,}/g, "-") + .replace(/^[-_]+|[-_]+$/g, ""); + + if (!safeBase) { + safeBase = "presentation"; + } + + if (safeBase.length > MAX_EXPORT_TITLE_LENGTH) { + safeBase = safeBase.slice(0, MAX_EXPORT_TITLE_LENGTH).replace(/[-_]+$/g, ""); + } + + if (!safeBase) { + safeBase = "presentation"; + } + + return `${safeBase}.${extension}`; +}; + const PresentationHeader = ({ presentation_id, isPresentationSaving, @@ -138,15 +170,13 @@ const PresentationHeader = ({ titleBlurIntentRef.current = "cancel"; }; - const get_presentation_pptx_model = async (id: string): Promise => { - const response = await fetch(`/api/presentation_to_pptx_model?id=${id}`); - const pptx_model = await response.json(); - return pptx_model; - }; - - const exportViaIpc = async (format: "pptx" | "pdf"): Promise => { - if (typeof window === 'undefined') return false; - if (!(window as any).electron?.exportPresentation) return false; + const exportViaIpc = async ( + format: "pptx" | "pdf", + title: string + ): Promise => { + if (typeof window === "undefined" || !(window as any).electron?.exportPresentation) { + throw new Error("Electron export bridge is unavailable"); + } trackEvent( format === "pptx" ? MixpanelEvent.Header_ExportAsPPTX_API_Call @@ -154,13 +184,12 @@ const PresentationHeader = ({ ); const result = await (window as any).electron.exportPresentation( presentation_id, - presentationData?.title || 'presentation', + title, format ); if (!result?.success) { - throw new Error(result?.message || 'Export failed'); + throw new Error(result?.message || "Export failed"); } - return true; }; const handleExportPptx = async () => { @@ -172,25 +201,13 @@ const PresentationHeader = ({ // Save the presentation data before exporting trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call); await PresentationGenerationApi.updatePresentationContent(presentationData); - - if (await exportViaIpc("pptx")) { - toast.success("PPTX exported successfully!"); - return; - } - - trackEvent(MixpanelEvent.Header_GetPptxModel_API_Call); - const pptx_model = await get_presentation_pptx_model(presentation_id); - if (!pptx_model) { - throw new Error("Failed to get presentation PPTX model"); - } - trackEvent(MixpanelEvent.Header_ExportAsPPTX_API_Call); - const pptx_path = await PresentationGenerationApi.exportAsPPTX(pptx_model); - if (pptx_path) { - // window.open(pptx_path, '_self'); - downloadLink(pptx_path); - } else { - throw new Error("No path returned from export"); - } + const safePptxFileName = buildSafeExportFileName( + presentationData?.title, + "pptx" + ); + const safePptxTitle = safePptxFileName.replace(/\.pptx$/i, ""); + await exportViaIpc("pptx", safePptxTitle); + toast.success("PPTX exported successfully!"); } catch (error) { console.error("Export failed:", error); toast.error("Having trouble exporting!", { @@ -211,27 +228,13 @@ const PresentationHeader = ({ // Save the presentation data before exporting trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call); await PresentationGenerationApi.updatePresentationContent(presentationData); - - trackEvent(MixpanelEvent.Header_ExportAsPDF_API_Call); - if (await exportViaIpc("pdf")) { - toast.success("PDF exported successfully!"); - return; - } - const response = await fetch('/api/export-as-pdf', { - method: 'POST', - body: JSON.stringify({ - id: presentation_id, - title: presentationData?.title, - }) - }); - - if (response.ok) { - const { path: pdfPath } = await response.json(); - // window.open(pdfPath, '_blank'); - downloadLink(pdfPath); - } else { - throw new Error("Failed to export PDF"); - } + const safePdfFileName = buildSafeExportFileName( + presentationData?.title, + "pdf" + ); + const safePdfTitle = safePdfFileName.replace(/\.pdf$/i, ""); + await exportViaIpc("pdf", safePdfTitle); + toast.success("PDF exported successfully!"); } catch (err) { console.error(err); @@ -249,19 +252,6 @@ const PresentationHeader = ({ trackEvent(MixpanelEvent.Header_ReGenerate_Button_Clicked, { pathname }); router.push(`/presentation?id=${presentation_id}&stream=true`); }; - const downloadLink = (path: string) => { - // if we have popup access give direct download if not redirect to the path - if (window.opener) { - window.open(path, '_blank'); - } else { - const link = document.createElement('a'); - link.href = path; - link.download = path.split('/').pop() || 'download'; - document.body.appendChild(link); - link.click(); - } - }; - const ExportOptions = ({ mobile }: { mobile: boolean }) => (

Export as

diff --git a/electron/servers/nextjs/app/(presentation-generator)/services/api/presentation-generation.ts b/electron/servers/nextjs/app/(presentation-generator)/services/api/presentation-generation.ts index 675c1fcf..51f8acf8 100644 --- a/electron/servers/nextjs/app/(presentation-generator)/services/api/presentation-generation.ts +++ b/electron/servers/nextjs/app/(presentation-generator)/services/api/presentation-generation.ts @@ -226,27 +226,4 @@ export class PresentationGenerationApi { } } - - - // EXPORT PRESENTATION - static async exportAsPPTX(presentationData: any) { - try { - const response = await fetch( - getApiUrl(`/api/v1/ppt/presentation/export/pptx`), - { - method: "POST", - headers: getHeader(), - body: JSON.stringify(presentationData), - cache: "no-cache", - } - ); - return await ApiResponseHandler.handleResponse(response, "Failed to export as PowerPoint"); - } catch (error) { - console.error("error in pptx export", error); - throw error; - } - } - - - -} \ No newline at end of file +} diff --git a/electron/servers/nextjs/app/api/export-as-pdf/route.ts b/electron/servers/nextjs/app/api/export-as-pdf/route.ts deleted file mode 100644 index ebe5bf60..00000000 --- a/electron/servers/nextjs/app/api/export-as-pdf/route.ts +++ /dev/null @@ -1,114 +0,0 @@ -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 } - ); - } - - // Get the Next.js server URL from environment variable or construct from request - // NEXT_PUBLIC_URL is set by Electron app and includes protocol (e.g., "http://127.0.0.1:40001") - let nextjsUrl = process.env.NEXT_PUBLIC_URL; - if (!nextjsUrl) { - // In Docker environment, use localhost (goes through nginx on port 80) - // This ensures API calls from the page use window.location.origin = http://localhost - // which routes /api/v1/ through nginx to FastAPI correctly - nextjsUrl = 'http://localhost'; - } - - 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", - "--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(`${nextjsUrl}/pdf-maker?id=${id}`, { - waitUntil: "networkidle0", - timeout: 300000, - }); - - await page.waitForFunction('() => document.readyState === "complete"'); - - try { - await page.waitForFunction( - ` - () => { - const allElements = document.querySelectorAll('*'); - let loadedElements = 0; - let totalElements = allElements.length; - - for (let el of allElements) { - const style = window.getComputedStyle(el); - const isVisible = style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0'; - - if (isVisible && el.offsetWidth > 0 && el.offsetHeight > 0) { - loadedElements++; - } - } - - return (loadedElements / totalElements) >= 0.99; - } - `, - { timeout: 300000 } - ); - - 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", - printBackground: true, - margin: { top: 0, right: 0, bottom: 0, left: 0 }, - }); - - browser.close(); - - const sanitizedTitle = sanitizeFilename(title ?? "presentation"); - const appDataDirectory = process.env.APP_DATA_DIRECTORY!; - if (!appDataDirectory) { - return NextResponse.json({ - error: "App data directory not found", - status: 500, - }); - } - const destinationPath = path.join( - appDataDirectory, - "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, - }); -} diff --git a/electron/servers/nextjs/app/api/presentation_to_pptx_model/route.ts b/electron/servers/nextjs/app/api/presentation_to_pptx_model/route.ts deleted file mode 100644 index 40655e29..00000000 --- a/electron/servers/nextjs/app/api/presentation_to_pptx_model/route.ts +++ /dev/null @@ -1,1217 +0,0 @@ -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 { 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 sharp from "sharp"; - -interface GetAllChildElementsAttributesArgs { - element: ElementHandle; - rootRect?: { - left: number; - top: number; - width: number; - height: number; - } | null; - depth?: number; - inheritedFont?: ElementAttributes["font"]; - inheritedBackground?: ElementAttributes["background"]; - inheritedBorderRadius?: number[]; - inheritedZIndex?: number; - inheritedOpacity?: number; - screenshotsDir: string; -} - -export async function GET(request: NextRequest) { - // Check if running in Electron/static export mode - // This route requires server-side Puppeteer execution which is not available in static exports - // - In Electron builds (BUILD_TARGET=electron): Return stub response - // - In development mode: Full functionality with Puppeteer - // - In Docker/server deployments: Full functionality with Puppeteer - const isStaticMode = process.env.BUILD_TARGET === 'electron' || - process.env.IS_STATIC_EXPORT === 'true' || - process.env.NEXT_RUNTIME === 'edge'; - - if (isStaticMode) { - return NextResponse.json( - { - error: "This API route requires a server runtime and is not available in static export mode", - message: "This functionality is only available in development mode or Docker deployment with Puppeteer support" - }, - { status: 501 } - ); - } - - // Full functionality for development/Docker - let browser: Browser | null = null; - let page: Page | null = null; - - try { - const id = await getPresentationId(request); - [browser, page] = await getBrowserAndPage(id); - const screenshotsDir = getScreenshotsDir(); - - 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); - const presentation_pptx_model: PptxPresentationModel = { - slides: slides_pptx_models, - }; - - await closeBrowserAndPage(browser, page); - - return NextResponse.json(presentation_pptx_model); - } catch (error: any) { - console.error(error); - await closeBrowserAndPage(browser, page); - if (error instanceof ApiError) { - return NextResponse.json(error, { status: 400 }); - } - return NextResponse.json( - { detail: `Internal server error: ${error.message}` }, - { status: 500 } - ); - } -} - -async function getPresentationId(request: NextRequest) { - const id = request.nextUrl.searchParams.get("id"); - if (!id) { - throw new ApiError("Presentation ID not found"); - } - return id; -} - -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", - "--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, deviceScaleFactor: 1 }); - page.setDefaultNavigationTimeout(300000); - page.setDefaultTimeout(300000); - await page.goto(`http://localhost/pdf-maker?id=${id}`, { - waitUntil: "networkidle0", - timeout: 300000, - }); - return [browser, page]; -} - -async function closeBrowserAndPage(browser: Browser | null, page: Page | null) { - await page?.close(); - await browser?.close(); -} - -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"); - } - 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[] -) { - 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.element = undefined; - } - } - slideAttributes.speakerNote = speakerNotes[index]; - } -} - -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") { - 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(); - - const hideAllExcept = (targetElement: Element) => { - const allElements = document.querySelectorAll("*"); - - 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; - } - - (elem as HTMLElement).style.opacity = "0"; - }); - }; - - hideAllExcept(el); - - (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, - }); - if (!screenshot) { - throw new ApiError("Failed to screenshot element"); - } - - await element.element?.evaluate((el) => { - if ((el as any).__restoreStyles) { - (el as any).__restoreStyles(); - } - }); - - return screenshotPath; -} - -const convertSvgToPng = async (element_attibutes: ElementAttributes) => { - const svgHtml = - (await element_attibutes.element?.evaluate((el) => { - // Apply font color - const fontColor = window.getComputedStyle(el).color; - (el as HTMLElement).style.color = fontColor; - - 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") - .toBuffer(); - return pngBuffer; -}; - -async function getSlidesAttributes( - slides: ElementHandle[], - screenshotsDir: string -): Promise { - const slideAttributes = await Promise.all( - 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); - const slides = await slides_wrapper.$$(":scope > div > div"); - return { slides, speakerNotes }; -} - -async function getSlidesWrapper(page: Page): Promise> { - const slides_wrapper = await page.$("#presentation-slides-wrapper"); - if (!slides_wrapper) { - throw new ApiError("Presentation slides not found"); - } - return slides_wrapper; -} - -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") || "" - ); - }); -} - -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; - inheritedBackground = rootAttributes.background; - inheritedZIndex = rootAttributes.zIndex; - inheritedOpacity = rootAttributes.opacity; - 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 > *"); - - 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) - ) { - continue; - } - - if ( - inheritedFont && - !attributes.font && - attributes.innerText && - attributes.innerText.trim().length > 0 - ) { - attributes.font = inheritedFont; - } - if (inheritedBackground && !attributes.background && attributes.shadow) { - attributes.background = inheritedBackground; - } - if (inheritedBorderRadius && !attributes.borderRadius) { - attributes.borderRadius = inheritedBorderRadius; - } - if (inheritedZIndex !== undefined && attributes.zIndex === 0) { - attributes.zIndex = inheritedZIndex; - } - if ( - inheritedOpacity !== undefined && - (attributes.opacity === undefined || attributes.opacity === 1) - ) { - attributes.opacity = inheritedOpacity; - } - - 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, - width: attributes.position.width, - height: attributes.position.height, - }; - } - - // 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 - ) { - continue; - } - - // If element is paragraph and contains only inline formatting tags, don't go deeper - if (attributes.tagName === "p") { - const innerElementTagNames = await childElementHandle.evaluate((el) => { - 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) - ); - - if (innerElementTagNames.length > 0 && hasOnlyAllowedInlineTags) { - attributes.innerText = await childElementHandle.evaluate((el) => { - return el.innerHTML; - }); - allResults.push({ attributes, depth }); - continue; - } - } - - if ( - attributes.tagName === "svg" || - attributes.tagName === "canvas" || - attributes.tagName === "table" - ) { - attributes.should_screenshot = true; - attributes.element = childElementHandle; - } - - 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") { - continue; - } - - const childResults = await getAllChildElementsAttributes({ - element: childElementHandle, - rootRect: rootRect, - depth: depth + 1, - inheritedFont: attributes.font || inheritedFont, - inheritedBackground: attributes.background || inheritedBackground, - inheritedBorderRadius: attributes.borderRadius || inheritedBorderRadius, - inheritedZIndex: attributes.zIndex || inheritedZIndex, - inheritedOpacity: attributes.opacity || inheritedOpacity, - screenshotsDir, - }); - 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 && - attributes.position.left === 0 && - attributes.position.top === 0 && - attributes.position.width === rootRect!.width && - attributes.position.height === rootRect!.height - ); - }); - - for (const { attributes } of elementsWithRootPosition) { - if (attributes.background && attributes.background.color) { - backgroundColor = attributes.background.color; - break; - } - } - } - - 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 hasVisualProperties = - hasBackground || hasBorder || hasShadow || hasText; - const hasSpecialContent = hasImage || isSvg || isCanvas || isTable; - - return (hasVisualProperties && !occupiesRoot) || hasSpecialContent; - }) - : allResults; - - 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 a.depth - b.depth; - } - - return zIndexB - zIndexA; - }) - .map(({ attributes }) => { - if ( - attributes.shadow && - attributes.shadow.color && - (!attributes.background || !attributes.background.color) && - backgroundColor - ) { - attributes.background = { - color: backgroundColor, - opacity: undefined, - }; - } - return attributes; - }); - - return { - elements: sortedElements, - backgroundColor, - }; - } else { - return { - elements: filteredResults.map(({ attributes }) => attributes), - backgroundColor, - }; - } -} - -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)") { - return { hex: undefined, opacity: undefined }; - } - - if (color.startsWith("rgba(") || color.startsWith("hsla(")) { - const match = color.match(/rgba?\(([^)]+)\)|hsla?\(([^)]+)\)/); - if (match) { - const values = match[1] || match[2]; - const parts = values.split(",").map((part) => part.trim()); - - if (parts.length >= 4) { - const opacity = parseFloat(parts[3]); - const rgbColor = color - .replace(/rgba?\(|hsla?\(|\)/g, "") - .split(",") - .slice(0, 3) - .join(","); - const rgbString = color.startsWith("rgba") - ? `rgb(${rgbColor})` - : `hsl(${rgbColor})`; - - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (ctx) { - ctx.fillStyle = rgbString; - const hexColor = ctx.fillStyle; - const hex = hexColor.startsWith("#") - ? hexColor.substring(1) - : hexColor; - const result = { - hex, - opacity: isNaN(opacity) ? undefined : opacity, - }; - - return result; - } - } - } - } - - if (color.startsWith("rgb(") || color.startsWith("hsl(")) { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (ctx) { - ctx.fillStyle = color; - const hexColor = ctx.fillStyle; - const hex = hexColor.startsWith("#") - ? hexColor.substring(1) - : hexColor; - return { hex, opacity: undefined }; - } - } - - if (color.startsWith("#")) { - const hex = color.substring(1); - return { hex, opacity: undefined }; - } - - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (!ctx) return { hex: color, opacity: undefined }; - - ctx.fillStyle = color; - const hexColor = ctx.fillStyle; - const hex = hexColor.startsWith("#") ? hexColor.substring(1) : hexColor; - const result = { hex, opacity: undefined }; - - return result; - } - - function hasOnlyTextNodes(el: Element): boolean { - const children = el.childNodes; - for (let i = 0; i < children.length; i++) { - const child = children[i]; - if (child.nodeType === Node.ELEMENT_NODE) { - return false; - } - } - return true; - } - - function parsePosition(el: Element) { - const rect = el.getBoundingClientRect(); - return { - left: isFinite(rect.left) ? rect.left : 0, - top: isFinite(rect.top) ? rect.top : 0, - width: isFinite(rect.width) ? rect.width : 0, - height: isFinite(rect.height) ? rect.height : 0, - }; - } - - function parseBackground(computedStyles: CSSStyleDeclaration) { - const backgroundColorResult = colorToHex(computedStyles.backgroundColor); - - 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 parseBackgroundImage(computedStyles: CSSStyleDeclaration) { - const backgroundImage = computedStyles.backgroundImage; - - if (!backgroundImage || backgroundImage === "none") { - return undefined; - } - - // Extract URL from background-image style - const urlMatch = backgroundImage.match(/url\(['"]?([^'"]+)['"]?\)/); - if (urlMatch && urlMatch[1]) { - return urlMatch[1]; - } - - return undefined; - } - - function parseBorder(computedStyles: CSSStyleDeclaration) { - const borderColorResult = colorToHex(computedStyles.borderColor); - const borderWidth = parseFloat(computedStyles.borderWidth); - - 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) { - const boxShadow = computedStyles.boxShadow; - if (boxShadow !== "none") { - } - let shadow: { - offset?: [number, number]; - color?: string; - opacity?: number; - radius?: number; - angle?: number; - spread?: number; - inset?: boolean; - } = {}; - - if (boxShadow && boxShadow !== "none") { - const shadows: string[] = []; - let currentShadow = ""; - let parenCount = 0; - - for (let i = 0; i < boxShadow.length; i++) { - const char = boxShadow[i]; - if (char === "(") { - parenCount++; - } else if (char === ")") { - parenCount--; - } else if (char === "," && parenCount === 0) { - shadows.push(currentShadow.trim()); - currentShadow = ""; - continue; - } - currentShadow += char; - } - - if (currentShadow.trim()) { - shadows.push(currentShadow.trim()); - } - - let selectedShadow = ""; - let bestShadowScore = -1; - - for (let i = 0; i < shadows.length; i++) { - const shadowStr = shadows[i]; - - const shadowParts = shadowStr.split(" "); - const numericParts: number[] = []; - const colorParts: string[] = []; - let isInset = false; - let currentColor = ""; - let inColorFunction = false; - - for (let j = 0; j < shadowParts.length; j++) { - const part = shadowParts[j]; - const trimmedPart = part.trim(); - if (trimmedPart === "") continue; - - if (trimmedPart.toLowerCase() === "inset") { - isInset = true; - continue; - } - - if (trimmedPart.match(/^(rgba?|hsla?)\s*\(/i)) { - inColorFunction = true; - currentColor = trimmedPart; - continue; - } - - if (inColorFunction) { - currentColor += " " + trimmedPart; - - const openParens = (currentColor.match(/\(/g) || []).length; - const closeParens = (currentColor.match(/\)/g) || []).length; - - if (openParens <= closeParens) { - colorParts.push(currentColor); - currentColor = ""; - inColorFunction = false; - } - continue; - } - - const numericValue = parseFloat(trimmedPart); - if (!isNaN(numericValue)) { - numericParts.push(numericValue); - } else { - colorParts.push(trimmedPart); - } - } - - let hasVisibleColor = false; - if (colorParts.length > 0) { - const shadowColor = colorParts.join(" "); - const colorResult = colorToHex(shadowColor); - hasVisibleColor = !!( - colorResult.hex && - colorResult.hex !== "000000" && - colorResult.opacity !== 0 - ); - } - - const hasNonZeroValues = numericParts.some((value) => value !== 0); - - let shadowScore = 0; - if (hasNonZeroValues) { - shadowScore += numericParts.filter((value) => value !== 0).length; - } - if (hasVisibleColor) { - shadowScore += 2; - } - - if ( - (hasNonZeroValues || hasVisibleColor) && - shadowScore > bestShadowScore - ) { - selectedShadow = shadowStr; - bestShadowScore = shadowScore; - } - } - - if (!selectedShadow && shadows.length > 0) { - selectedShadow = shadows[0]; - } - - if (selectedShadow) { - const shadowParts = selectedShadow.split(" "); - const numericParts: number[] = []; - const colorParts: string[] = []; - let isInset = false; - let currentColor = ""; - let inColorFunction = false; - - for (let i = 0; i < shadowParts.length; i++) { - const part = shadowParts[i]; - const trimmedPart = part.trim(); - if (trimmedPart === "") continue; - - if (trimmedPart.toLowerCase() === "inset") { - isInset = true; - continue; - } - - if (trimmedPart.match(/^(rgba?|hsla?)\s*\(/i)) { - inColorFunction = true; - currentColor = trimmedPart; - continue; - } - - if (inColorFunction) { - currentColor += " " + trimmedPart; - - const openParens = (currentColor.match(/\(/g) || []).length; - const closeParens = (currentColor.match(/\)/g) || []).length; - - if (openParens <= closeParens) { - colorParts.push(currentColor); - currentColor = ""; - inColorFunction = false; - } - continue; - } - - const numericValue = parseFloat(trimmedPart); - if (!isNaN(numericValue)) { - numericParts.push(numericValue); - } else { - colorParts.push(trimmedPart); - } - } - - if (numericParts.length >= 2) { - const offsetX = numericParts[0]; - const offsetY = numericParts[1]; - const blurRadius = numericParts.length >= 3 ? numericParts[2] : 0; - const spreadRadius = numericParts.length >= 4 ? numericParts[3] : 0; - - // Only create shadow if color is present - if (colorParts.length > 0) { - const shadowColor = colorParts.join(" "); - const shadowColorResult = colorToHex(shadowColor); - - 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; - } - - function parseFont(computedStyles: CSSStyleDeclaration) { - const fontSize = parseFloat(computedStyles.fontSize); - const fontWeight = parseInt(computedStyles.fontWeight); - const fontColorResult = colorToHex(computedStyles.color); - const fontFamily = computedStyles.fontFamily; - const fontStyle = computedStyles.fontStyle; - - let fontName = undefined; - if (fontFamily !== "initial") { - const firstFont = fontFamily.split(",")[0].trim().replace(/['"]/g, ""); - fontName = firstFont; - } - - 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) { - const lineHeight = computedStyles.lineHeight; - const innerText = el.textContent || ""; - - const htmlEl = el as HTMLElement; - - const fontSize = parseFloat(computedStyles.fontSize); - const computedLineHeight = parseFloat(computedStyles.lineHeight); - - const singleLineHeight = !isNaN(computedLineHeight) - ? computedLineHeight - : fontSize * 1.2; - - const hasExplicitLineBreaks = - innerText.includes("\n") || - innerText.includes("\r") || - innerText.includes("\r\n"); - const hasTextWrapping = htmlEl.offsetHeight > singleLineHeight * 2; - const hasOverflow = htmlEl.scrollHeight > htmlEl.clientHeight; - - const isMultiline = - hasExplicitLineBreaks || hasTextWrapping || hasOverflow; - - 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); - const marginLeft = parseFloat(computedStyles.marginLeft); - const marginRight = parseFloat(computedStyles.marginRight); - const marginObj = { - top: isNaN(marginTop) ? undefined : marginTop, - bottom: isNaN(marginBottom) ? undefined : marginBottom, - left: isNaN(marginLeft) ? undefined : marginLeft, - right: isNaN(marginRight) ? undefined : marginRight, - }; - - return marginObj.top === 0 && - marginObj.bottom === 0 && - marginObj.left === 0 && - marginObj.right === 0 - ? undefined - : marginObj; - } - - function parsePadding(computedStyles: CSSStyleDeclaration) { - const paddingTop = parseFloat(computedStyles.paddingTop); - const paddingBottom = parseFloat(computedStyles.paddingBottom); - const paddingLeft = parseFloat(computedStyles.paddingLeft); - const paddingRight = parseFloat(computedStyles.paddingRight); - const paddingObj = { - top: isNaN(paddingTop) ? undefined : paddingTop, - bottom: isNaN(paddingBottom) ? undefined : paddingBottom, - left: isNaN(paddingLeft) ? undefined : paddingLeft, - right: isNaN(paddingRight) ? undefined : paddingRight, - }; - - return paddingObj.top === 0 && - paddingObj.bottom === 0 && - paddingObj.left === 0 && - paddingObj.right === 0 - ? undefined - : paddingObj; - } - - function parseBorderRadius( - computedStyles: CSSStyleDeclaration, - el: Element - ) { - const borderRadius = computedStyles.borderRadius; - let borderRadiusValue; - - if (borderRadius && borderRadius !== "0px") { - const radiusParts = borderRadius - .split(" ") - .map((part) => parseFloat(part)); - if (radiusParts.length === 1) { - borderRadiusValue = [ - radiusParts[0], - radiusParts[0], - radiusParts[0], - radiusParts[0], - ]; - } else if (radiusParts.length === 2) { - borderRadiusValue = [ - radiusParts[0], - radiusParts[1], - radiusParts[0], - radiusParts[1], - ]; - } else if (radiusParts.length === 3) { - borderRadiusValue = [ - radiusParts[0], - radiusParts[1], - radiusParts[2], - radiusParts[1], - ]; - } else if (radiusParts.length === 4) { - borderRadiusValue = radiusParts; - } - - // Clamp border radius values to be between 0 and half the width/height - if (borderRadiusValue) { - 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 - const maxRadius = - index === 0 || index === 2 ? maxRadiusX : maxRadiusY; - return Math.max(0, Math.min(radius, maxRadius)); - }); - } - } - - return borderRadiusValue; - } - - function parseShape(el: Element, borderRadiusValue: number[] | undefined) { - if (el.tagName.toLowerCase() === "img") { - return borderRadiusValue && - borderRadiusValue.length === 4 && - borderRadiusValue.every((radius: number) => radius === 50) - ? "circle" - : "rectangle"; - } - return undefined; - } - - function parseFilters(computedStyles: CSSStyleDeclaration) { - const filter = computedStyles.filter; - if (!filter || filter === "none") { - return undefined; - } - - const filters: { - invert?: number; - brightness?: number; - contrast?: number; - saturate?: number; - hueRotate?: number; - blur?: number; - grayscale?: number; - sepia?: number; - opacity?: number; - } = {}; - - // Parse filter functions - const filterFunctions = filter.match(/[a-zA-Z]+\([^)]*\)/g); - if (filterFunctions) { - filterFunctions.forEach((func) => { - const match = func.match(/([a-zA-Z]+)\(([^)]*)\)/); - if (match) { - const filterType = match[1]; - const value = parseFloat(match[2]); - - if (!isNaN(value)) { - switch (filterType) { - case "invert": - filters.invert = value; - break; - case "brightness": - filters.brightness = value; - break; - case "contrast": - filters.contrast = value; - break; - case "saturate": - filters.saturate = value; - break; - case "hue-rotate": - filters.hueRotate = value; - break; - case "blur": - filters.blur = value; - break; - case "grayscale": - filters.grayscale = value; - break; - case "sepia": - filters.sepia = value; - break; - case "opacity": - filters.opacity = value; - break; - } - } - } - }); - } - - // Return undefined if no filters were parsed - return Object.keys(filters).length > 0 ? filters : undefined; - } - - function parseElementAttributes(el: Element) { - let tagName = el.tagName.toLowerCase(); - - const computedStyles = window.getComputedStyle(el); - - const position = parsePosition(el); - - const shadow = parseShadow(computedStyles); - - const background = parseBackground(computedStyles); - - const border = parseBorder(computedStyles); - - const font = parseFont(computedStyles); - - const lineHeight = parseLineHeight(computedStyles, el); - - const margin = parseMargin(computedStyles); - - const padding = parsePadding(computedStyles); - - const innerText = hasOnlyTextNodes(el) - ? el.textContent || undefined - : undefined; - - const zIndex = parseInt(computedStyles.zIndex); - const zIndexValue = isNaN(zIndex) ? 0 : zIndex; - - const textAlign = computedStyles.textAlign as - | "left" - | "center" - | "right" - | "justify"; - const objectFit = computedStyles.objectFit as - | "contain" - | "cover" - | "fill" - | undefined; - - const parsedBackgroundImage = parseBackgroundImage(computedStyles); - const imageSrc = (el as HTMLImageElement).src || parsedBackgroundImage; - - const borderRadiusValue = parseBorderRadius(computedStyles, el); - - const shape = parseShape(el, borderRadiusValue) as - | "rectangle" - | "circle" - | undefined; - - const textWrap = computedStyles.whiteSpace !== "nowrap"; - - const filters = parseFilters(computedStyles); - - const opacity = parseFloat(computedStyles.opacity); - const elementOpacity = isNaN(opacity) ? undefined : opacity; - - return { - tagName: tagName, - id: el.id, - className: - el.className && typeof el.className === "string" - ? el.className - : el.className - ? el.className.toString() - : undefined, - innerText: innerText, - opacity: elementOpacity, - background: background, - border: border, - shadow: shadow, - font: font, - position: position, - margin: margin, - padding: padding, - zIndex: zIndexValue, - textAlign: textAlign !== "left" ? textAlign : undefined, - lineHeight: lineHeight, - borderRadius: borderRadiusValue, - imageSrc: imageSrc, - objectFit: objectFit, - clip: false, - overlay: undefined, - shape: shape, - connectorType: undefined, - textWrap: textWrap, - should_screenshot: false, - element: undefined, - filters: filters, - }; - } - - return parseElementAttributes(el); - }); - return attributes; -} diff --git a/electron/servers/nextjs/types/element_attibutes.ts b/electron/servers/nextjs/types/element_attibutes.ts deleted file mode 100644 index 00d81b84..00000000 --- a/electron/servers/nextjs/types/element_attibutes.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { ElementHandle } from "puppeteer"; - -export interface ElementAttributes { - tagName: string; - id?: string; - className?: string; - innerText?: string; - opacity?: number; - background?: { - color?: string; - opacity?: number; - }; - border?: { - color?: string; - width?: number; - opacity?: number; - }; - shadow?: { - offset?: [number, number]; - color?: string; - opacity?: number; - radius?: number; - angle?: number; - spread?: number; - inset?: boolean; - }, - font?: { - name?: string; - size?: number; - weight?: number; - color?: string; - italic?: boolean; - }; - position?: { - left?: number; - top?: number; - width?: number; - height?: number; - }; - margin?: { - top?: number; - bottom?: number; - left?: number; - right?: number; - }; - padding?: { - top?: number; - bottom?: number; - left?: number; - right?: number; - }; - zIndex?: number; - textAlign?: 'left' | 'center' | 'right' | 'justify'; - lineHeight?: number; - borderRadius?: number[]; - imageSrc?: string; - objectFit?: 'contain' | 'cover' | 'fill'; - clip?: boolean; - overlay?: string; - shape?: 'rectangle' | 'circle'; - connectorType?: string; - textWrap?: boolean; - should_screenshot?: boolean; - element?: ElementHandle; - filters?: { - invert?: number; - brightness?: number; - contrast?: number; - saturate?: number; - hueRotate?: number; - blur?: number; - grayscale?: number; - sepia?: number; - opacity?: number; - }; -} - -export interface SlideAttributesResult { - elements: ElementAttributes[]; - backgroundColor?: string; - speakerNote?: string; -} \ No newline at end of file diff --git a/electron/servers/nextjs/types/global.d.ts b/electron/servers/nextjs/types/global.d.ts index e810f145..ce51712e 100644 --- a/electron/servers/nextjs/types/global.d.ts +++ b/electron/servers/nextjs/types/global.d.ts @@ -16,7 +16,11 @@ interface TextFrameProps { // Electron IPC types interface ElectronAPI { fileDownloaded: (filePath: string) => Promise; - exportAsPDF: (id: string, title: string) => Promise; + exportPresentation: ( + id: string, + title: string, + format: "pptx" | "pdf" + ) => Promise; getUserConfig: () => Promise; setUserConfig: (userConfig: any) => Promise; getCanChangeKeys: () => Promise; diff --git a/electron/servers/nextjs/types/pptx_models.ts b/electron/servers/nextjs/types/pptx_models.ts deleted file mode 100644 index 8cf2e7c0..00000000 --- a/electron/servers/nextjs/types/pptx_models.ts +++ /dev/null @@ -1,364 +0,0 @@ -export enum PptxBoxShapeEnum { - RECTANGLE = "rectangle", - CIRCLE = "circle" -} - -export enum PptxObjectFitEnum { - CONTAIN = "contain", - COVER = "cover", - FILL = "fill" -} - -export enum PptxAlignment { - CENTER = 2, - DISTRIBUTE = 5, - JUSTIFY = 4, - JUSTIFY_LOW = 7, - LEFT = 1, - RIGHT = 3, - THAI_DISTRIBUTE = 6, - MIXED = -2 -} - -export enum PptxShapeType { - ACTION_BUTTON_BACK_OR_PREVIOUS = 129, - ACTION_BUTTON_BEGINNING = 131, - ACTION_BUTTON_CUSTOM = 125, - ACTION_BUTTON_DOCUMENT = 134, - ACTION_BUTTON_END = 132, - ACTION_BUTTON_FORWARD_OR_NEXT = 130, - ACTION_BUTTON_HELP = 127, - ACTION_BUTTON_HOME = 126, - ACTION_BUTTON_INFORMATION = 128, - ACTION_BUTTON_MOVIE = 136, - ACTION_BUTTON_RETURN = 133, - ACTION_BUTTON_SOUND = 135, - ARC = 25, - BALLOON = 137, - BENT_ARROW = 41, - BENT_UP_ARROW = 44, - BEVEL = 15, - BLOCK_ARC = 20, - CAN = 13, - CHART_PLUS = 182, - CHART_STAR = 181, - CHART_X = 180, - CHEVRON = 52, - CHORD = 161, - CIRCULAR_ARROW = 60, - CLOUD = 179, - CLOUD_CALLOUT = 108, - CORNER = 162, - CORNER_TABS = 169, - CROSS = 11, - CUBE = 14, - CURVED_DOWN_ARROW = 48, - CURVED_DOWN_RIBBON = 100, - CURVED_LEFT_ARROW = 46, - CURVED_RIGHT_ARROW = 45, - CURVED_UP_ARROW = 47, - CURVED_UP_RIBBON = 99, - DECAGON = 144, - DIAGONAL_STRIPE = 141, - DIAMOND = 4, - DODECAGON = 146, - DONUT = 18, - DOUBLE_BRACE = 27, - DOUBLE_BRACKET = 26, - DOUBLE_WAVE = 104, - DOWN_ARROW = 36, - DOWN_ARROW_CALLOUT = 56, - DOWN_RIBBON = 98, - EXPLOSION1 = 89, - EXPLOSION2 = 90, - FLOWCHART_ALTERNATE_PROCESS = 62, - FLOWCHART_CARD = 75, - FLOWCHART_COLLATE = 79, - FLOWCHART_CONNECTOR = 73, - FLOWCHART_DATA = 64, - FLOWCHART_DECISION = 63, - FLOWCHART_DELAY = 84, - FLOWCHART_DIRECT_ACCESS_STORAGE = 87, - FLOWCHART_DISPLAY = 88, - FLOWCHART_DOCUMENT = 67, - FLOWCHART_EXTRACT = 81, - FLOWCHART_INTERNAL_STORAGE = 66, - FLOWCHART_MAGNETIC_DISK = 86, - FLOWCHART_MANUAL_INPUT = 71, - FLOWCHART_MANUAL_OPERATION = 72, - FLOWCHART_MERGE = 82, - FLOWCHART_MULTIDOCUMENT = 68, - FLOWCHART_OFFLINE_STORAGE = 139, - FLOWCHART_OFFPAGE_CONNECTOR = 74, - FLOWCHART_OR = 78, - FLOWCHART_PREDEFINED_PROCESS = 65, - FLOWCHART_PREPARATION = 70, - FLOWCHART_PROCESS = 61, - FLOWCHART_PUNCHED_TAPE = 76, - FLOWCHART_SEQUENTIAL_ACCESS_STORAGE = 85, - FLOWCHART_SORT = 80, - FLOWCHART_STORED_DATA = 83, - FLOWCHART_SUMMING_JUNCTION = 77, - FLOWCHART_TERMINATOR = 69, - FOLDED_CORNER = 16, - FRAME = 158, - FUNNEL = 174, - GEAR_6 = 172, - GEAR_9 = 173, - HALF_FRAME = 159, - HEART = 21, - HEPTAGON = 145, - HEXAGON = 10, - HORIZONTAL_SCROLL = 102, - ISOSCELES_TRIANGLE = 7, - LEFT_ARROW = 34, - LEFT_ARROW_CALLOUT = 54, - LEFT_BRACE = 31, - LEFT_BRACKET = 29, - LEFT_CIRCULAR_ARROW = 176, - LEFT_RIGHT_ARROW = 37, - LEFT_RIGHT_ARROW_CALLOUT = 57, - LEFT_RIGHT_CIRCULAR_ARROW = 177, - LEFT_RIGHT_RIBBON = 140, - LEFT_RIGHT_UP_ARROW = 40, - LEFT_UP_ARROW = 43, - LIGHTNING_BOLT = 22, - LINE_CALLOUT_1 = 109, - LINE_CALLOUT_1_ACCENT_BAR = 113, - LINE_CALLOUT_1_BORDER_AND_ACCENT_BAR = 121, - LINE_CALLOUT_1_NO_BORDER = 117, - LINE_CALLOUT_2 = 110, - LINE_CALLOUT_2_ACCENT_BAR = 114, - LINE_CALLOUT_2_BORDER_AND_ACCENT_BAR = 122, - LINE_CALLOUT_2_NO_BORDER = 118, - LINE_CALLOUT_3 = 111, - LINE_CALLOUT_3_ACCENT_BAR = 115, - LINE_CALLOUT_3_BORDER_AND_ACCENT_BAR = 123, - LINE_CALLOUT_3_NO_BORDER = 119, - LINE_CALLOUT_4 = 112, - LINE_CALLOUT_4_ACCENT_BAR = 116, - LINE_CALLOUT_4_BORDER_AND_ACCENT_BAR = 124, - LINE_CALLOUT_4_NO_BORDER = 120, - LINE_INVERSE = 183, - MATH_DIVIDE = 166, - MATH_EQUAL = 167, - MATH_MINUS = 164, - MATH_MULTIPLY = 165, - MATH_NOT_EQUAL = 168, - MATH_PLUS = 163, - MOON = 24, - NON_ISOSCELES_TRAPEZOID = 143, - NOTCHED_RIGHT_ARROW = 50, - NO_SYMBOL = 19, - OCTAGON = 6, - OVAL = 9, - OVAL_CALLOUT = 107, - PARALLELOGRAM = 2, - PENTAGON = 51, - PIE = 142, - PIE_WEDGE = 175, - PLAQUE = 28, - PLAQUE_TABS = 171, - QUAD_ARROW = 39, - QUAD_ARROW_CALLOUT = 59, - RECTANGLE = 1, - RECTANGULAR_CALLOUT = 105, - REGULAR_PENTAGON = 12, - RIGHT_ARROW = 33, - RIGHT_ARROW_CALLOUT = 53, - RIGHT_BRACE = 32, - RIGHT_BRACKET = 30, - RIGHT_TRIANGLE = 8, - ROUNDED_RECTANGLE = 5, - ROUNDED_RECTANGULAR_CALLOUT = 106, - ROUND_1_RECTANGLE = 151, - ROUND_2_DIAG_RECTANGLE = 153, - ROUND_2_SAME_RECTANGLE = 152, - SMILEY_FACE = 17, - SNIP_1_RECTANGLE = 155, - SNIP_2_DIAG_RECTANGLE = 157, - SNIP_2_SAME_RECTANGLE = 156, - SNIP_ROUND_RECTANGLE = 154, - SQUARE_TABS = 170, - STAR_10_POINT = 149, - STAR_12_POINT = 150, - STAR_16_POINT = 94, - STAR_24_POINT = 95, - STAR_32_POINT = 96, - STAR_4_POINT = 91, - STAR_5_POINT = 92, - STAR_6_POINT = 147, - STAR_7_POINT = 148, - STAR_8_POINT = 93, - STRIPED_RIGHT_ARROW = 49, - SUN = 23, - SWOOSH_ARROW = 178, - TEAR = 160, - TRAPEZOID = 3, - UP_ARROW = 35, - UP_ARROW_CALLOUT = 55, - UP_DOWN_ARROW = 38, - UP_DOWN_ARROW_CALLOUT = 58, - UP_RIBBON = 97, - U_TURN_ARROW = 42, - VERTICAL_SCROLL = 101, - WAVE = 103 -} - -export enum PptxConnectorType { - CURVE = 3, - ELBOW = 2, - STRAIGHT = 1, - MIXED = -2 -} - -export interface PptxSpacingModel { - top: number; - bottom: number; - left: number; - right: number; -} - -export interface PptxPositionModel { - left: number; - top: number; - width: number; - height: number; -} - -export interface PptxFontModel { - name: string; - size: number; - font_weight: number; - italic: boolean; - color: string; -} - -export interface PptxFillModel { - color: string; - opacity: number; -} - -export interface PptxStrokeModel { - color: string; - thickness: number; - opacity: number; -} - -export interface PptxShadowModel { - radius: number; - offset: number; - color: string; - opacity: number; - angle: number; -} - -export interface PptxTextRunModel { - text: string; - font?: PptxFontModel; -} - -export interface PptxParagraphModel { - spacing?: PptxSpacingModel; - alignment?: PptxAlignment; - font?: PptxFontModel; - line_height?: number; - text?: string; - text_runs?: PptxTextRunModel[]; -} - -export interface PptxObjectFitModel { - fit?: PptxObjectFitEnum; - focus?: [number | null, number | null]; -} - -export interface PptxPictureModel { - is_network: boolean; - path: string; -} - -export interface PptxShapeModel { -} - -export interface PptxTextBoxModel extends PptxShapeModel { - shape_type: string; - margin?: PptxSpacingModel; - fill?: PptxFillModel; - position: PptxPositionModel; - text_wrap: boolean; - paragraphs: PptxParagraphModel[]; -} - -export interface PptxAutoShapeBoxModel extends PptxShapeModel { - shape_type: string; - type?: PptxShapeType; - margin?: PptxSpacingModel; - fill?: PptxFillModel; - stroke?: PptxStrokeModel; - shadow?: PptxShadowModel; - position: PptxPositionModel; - text_wrap: boolean; - border_radius?: number; - paragraphs?: PptxParagraphModel[]; -} - -export interface PptxPictureBoxModel extends PptxShapeModel { - shape_type: string; - position: PptxPositionModel; - margin?: PptxSpacingModel; - clip: boolean; - opacity?: number; - invert?: boolean; - border_radius?: number[]; - shape?: PptxBoxShapeEnum; - object_fit?: PptxObjectFitModel; - picture: PptxPictureModel; -} - -export interface PptxConnectorModel extends PptxShapeModel { - shape_type: string; - type?: PptxConnectorType; - position: PptxPositionModel; - thickness: number; - color: string; - opacity: number; -} - -export interface PptxSlideModel { - background?: PptxFillModel; - shapes: (PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel)[]; - note?: string; -} - -export interface PptxPresentationModel { - name?: string; - shapes?: PptxShapeModel[]; - slides: PptxSlideModel[]; -} - -export const createPptxSpacingAll = (num: number): PptxSpacingModel => ({ - top: num, - left: num, - bottom: num, - right: num -}); - -export const createPptxPositionForTextbox = (left: number, top: number, width: number): PptxPositionModel => ({ - left, - top, - width, - height: 100 -}); - -export const positionToPtList = (position: PptxPositionModel): number[] => { - return [position.left, position.top, position.width, position.height]; -}; - -export const positionToPtXyxy = (position: PptxPositionModel): number[] => { - const left = position.left; - const top = position.top; - const width = position.width; - const height = position.height; - - return [left, top, left + width, top + height]; -}; diff --git a/electron/servers/nextjs/utils/mixpanel.ts b/electron/servers/nextjs/utils/mixpanel.ts index 7acf42be..54e5f65e 100644 --- a/electron/servers/nextjs/utils/mixpanel.ts +++ b/electron/servers/nextjs/utils/mixpanel.ts @@ -19,7 +19,6 @@ export enum MixpanelEvent { Header_Export_PPTX_Button_Clicked = 'Header Export PPTX Button Clicked', Header_UpdatePresentationContent_API_Call = 'Header Update Presentation Content API Call', Header_ExportAsPDF_API_Call = 'Header Export As PDF API Call', - Header_GetPptxModel_API_Call = 'Header Get PPTX Model API Call', Header_ExportAsPPTX_API_Call = 'Header Export As PPTX API Call', Slide_Add_New_Slide_Button_Clicked = 'Slide Add New Slide Button Clicked', Slide_Delete_Slide_Button_Clicked = 'Slide Delete Slide Button Clicked', diff --git a/electron/servers/nextjs/utils/pptx_models_utils.ts b/electron/servers/nextjs/utils/pptx_models_utils.ts deleted file mode 100644 index 7cae718c..00000000 --- a/electron/servers/nextjs/utils/pptx_models_utils.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { ElementAttributes, SlideAttributesResult } from "@/types/element_attibutes"; -import { - PptxSlideModel, - PptxTextBoxModel, - PptxAutoShapeBoxModel, - PptxPictureBoxModel, - PptxConnectorModel, - PptxPositionModel, - PptxFillModel, - PptxStrokeModel, - PptxShadowModel, - PptxFontModel, - PptxParagraphModel, - PptxPictureModel, - PptxObjectFitModel, - PptxBoxShapeEnum, - PptxObjectFitEnum, - PptxAlignment, - PptxShapeType, - PptxConnectorType -} from "@/types/pptx_models"; - -function convertTextAlignToPptxAlignment(textAlign?: string): PptxAlignment | undefined { - if (!textAlign) return undefined; - - switch (textAlign.toLowerCase()) { - case 'left': - return PptxAlignment.LEFT; - case 'center': - return PptxAlignment.CENTER; - case 'right': - return PptxAlignment.RIGHT; - case 'justify': - return PptxAlignment.JUSTIFY; - default: - return PptxAlignment.LEFT; - } -} - -function convertLineHeightToRelative(lineHeight?: number, fontSize?: number): number | undefined { - if (!lineHeight) return undefined; - - let calculatedLineHeight = 1.2; - if (lineHeight < 10) { - calculatedLineHeight = lineHeight; - } - - if (fontSize && fontSize > 0) { - calculatedLineHeight = Math.round((lineHeight / fontSize) * 100) / 100; - } - - return calculatedLineHeight - 0.3 -} - -export function convertElementAttributesToPptxSlides( - slidesAttributes: SlideAttributesResult[] -): PptxSlideModel[] { - return slidesAttributes.map((slideAttributes) => { - const shapes = slideAttributes.elements.map(element => { - return convertElementToPptxShape(element); - }).filter(Boolean); - - const slide: PptxSlideModel = { - shapes: shapes as (PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel)[], - note: slideAttributes.speakerNote - }; - - if (slideAttributes.backgroundColor) { - slide.background = { - color: slideAttributes.backgroundColor, - opacity: 1.0 - }; - } - - return slide; - }); -} - -function convertElementToPptxShape( - element: ElementAttributes -): PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel | null { - - if (!element.position) { - return null; - } - - if (element.tagName === 'img' || (element.className && typeof element.className === 'string' && element.className.includes('image')) || element.imageSrc) { - return convertToPictureBox(element); - } - - if (element.innerText && element.innerText.trim().length > 0) { - // Use AutoShape model if there's background color and border radius - if (element.background?.color && element.borderRadius && element.borderRadius.some(radius => radius > 0)) { - return convertToAutoShapeBox(element); - } - return convertToTextBox(element); - } - - if (element.tagName === 'hr') { - return convertToConnector(element); - } - - return convertToAutoShapeBox(element); -} - -function convertToTextBox(element: ElementAttributes): PptxTextBoxModel { - const position: PptxPositionModel = { - left: Math.round(element.position?.left ?? 0), - top: Math.round(element.position?.top ?? 0), - width: Math.round(element.position?.width ?? 0), - height: Math.round(element.position?.height ?? 0) - }; - - const 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", - size: Math.round(element.font.size ?? 16), - font_weight: element.font.weight ?? 400, - italic: element.font.italic ?? false, - color: element.font.color ?? "000000" - } : undefined; - - const paragraph: PptxParagraphModel = { - spacing: undefined, - alignment: convertTextAlignToPptxAlignment(element.textAlign), - font, - line_height: convertLineHeightToRelative(element.lineHeight, element.font?.size), - text: element.innerText - }; - - return { - shape_type: "textbox", - margin: undefined, - fill, - position, - text_wrap: element.textWrap ?? true, - paragraphs: [paragraph] - }; -} - -function convertToAutoShapeBox(element: ElementAttributes): PptxAutoShapeBoxModel { - const position: PptxPositionModel = { - left: Math.round(element.position?.left ?? 0), - top: Math.round(element.position?.top ?? 0), - width: Math.round(element.position?.width ?? 0), - height: Math.round(element.position?.height ?? 0) - }; - const 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, - opacity: element.border.opacity ?? 1.0 - } : undefined; - - const shadow: PptxShadowModel | undefined = element.shadow?.color ? { - 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, - angle: Math.round(element.shadow.angle ?? 0) - } : undefined; - - 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), - font_weight: element.font.weight ?? 400, - italic: element.font.italic ?? false, - color: element.font.color ?? "000000" - } : undefined, - line_height: convertLineHeightToRelative(element.lineHeight, element.font?.size), - text: element.innerText - }] : undefined; - - const shapeType = element.borderRadius ? PptxShapeType.ROUNDED_RECTANGLE : PptxShapeType.RECTANGLE; - - let borderRadius = undefined; - for (const eachCornerRadius of element.borderRadius ?? []) { - if (eachCornerRadius > 0) { - borderRadius = Math.max(borderRadius ?? 0, eachCornerRadius); - } - } - - return { - shape_type: "autoshape", - type: shapeType, - margin: undefined, - fill, - stroke, - shadow, - position, - text_wrap: element.textWrap ?? true, - border_radius: borderRadius || undefined, - paragraphs - }; -} - -function convertToPictureBox(element: ElementAttributes): PptxPictureBoxModel { - const position: PptxPositionModel = { - left: Math.round(element.position?.left ?? 0), - top: Math.round(element.position?.top ?? 0), - width: Math.round(element.position?.width ?? 0), - height: Math.round(element.position?.height ?? 0) - }; - - const objectFit: PptxObjectFitModel = { - fit: element.objectFit ? (element.objectFit as PptxObjectFitEnum) : PptxObjectFitEnum.CONTAIN - }; - - const picture: PptxPictureModel = { - is_network: element.imageSrc ? element.imageSrc.startsWith('http') : false, - path: element.imageSrc || '' - }; - - return { - shape_type: "picture", - position, - margin: undefined, - clip: element.clip ?? true, - invert: element.filters?.invert === 1, - opacity: element.opacity, - 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 - }; -} - -function convertToConnector(element: ElementAttributes): PptxConnectorModel { - const position: PptxPositionModel = { - left: Math.round(element.position?.left ?? 0), - top: Math.round(element.position?.top ?? 0), - width: Math.round(element.position?.width ?? 0), - height: Math.round(element.position?.height ?? 0) - }; - - return { - shape_type: "connector", - type: PptxConnectorType.STRAIGHT, - position, - thickness: element.border?.width ?? 0.5, - color: element.border?.color || element.background?.color || '000000', - opacity: element.border?.opacity ?? 1.0 - }; -} diff --git a/package.json b/package.json index 4571d592..d154b650 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "presenton", "version": "1.0.0", - "presentationExportVersion": "v0.2.0", + "presentationExportVersion": "v0.2.2", "type": "module", "description": "Open-source AI presentation generator", "scripts": { diff --git a/servers/fastapi/api/v1/ppt/endpoints/presentation.py b/servers/fastapi/api/v1/ppt/endpoints/presentation.py index 24c63fc9..12052924 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/presentation.py +++ b/servers/fastapi/api/v1/ppt/endpoints/presentation.py @@ -23,7 +23,6 @@ from models.presentation_outline_model import ( ) from enums.tone import Tone from enums.verbosity import Verbosity -from models.pptx_models import PptxPresentationModel from models.presentation_structure_model import PresentationStructureModel from models.presentation_with_slides import ( PresentationWithSlides, @@ -46,14 +45,12 @@ from models.sql.presentation_layout_code import PresentationLayoutCodeModel from models.sse_response import SSECompleteResponse, SSEErrorResponse, SSEResponse from services.database import get_async_session -from services.temp_file_service import TEMP_FILE_SERVICE from services.concurrent_service import CONCURRENT_SERVICE from models.sql.presentation import PresentationModel -from services.pptx_presentation_creator import PptxPresentationCreator from models.sql.async_presentation_generation_status import ( AsyncPresentationGenerationTaskModel, ) -from utils.asset_directory_utils import get_exports_directory, get_images_directory +from utils.asset_directory_utils import get_images_directory from utils.llm_calls.generate_presentation_structure import ( generate_presentation_structure, ) @@ -501,56 +498,6 @@ async def update_presentation( slides=response_slides, fonts=fonts, ) - - -@PRESENTATION_ROUTER.post("/export/pptx", response_model=str) -async def export_presentation_as_pptx( - pptx_model: Annotated[PptxPresentationModel, Body()], -): - temp_dir = TEMP_FILE_SERVICE.create_temp_dir() - - pptx_creator = PptxPresentationCreator(pptx_model, temp_dir) - await pptx_creator.create_ppt() - - export_directory = get_exports_directory() - pptx_path = os.path.join( - export_directory, f"{pptx_model.name or uuid.uuid4()}.pptx" - ) - pptx_creator.save(pptx_path) - - return pptx_path - - -@PRESENTATION_ROUTER.post("/export", response_model=PresentationPathAndEditPath) -async def export_presentation_as_pptx_or_pdf( - id: Annotated[uuid.UUID, Body(description="Presentation ID to export")], - export_as: Annotated[ - Literal["pptx", "pdf"], Body(description="Format to export the presentation as") - ] = "pptx", - sql_session: AsyncSession = Depends(get_async_session), -): - """ - Export a presentation as PPTX or PDF. - This Api is used to export via the nextjs app i.e using the puppeteer to export the presentation. - - """ - presentation = await sql_session.get(PresentationModel, id) - - if not presentation: - raise HTTPException(status_code=404, detail="Presentation not found") - - presentation_and_path = await export_presentation( - id, - presentation.title or str(uuid.uuid4()), - export_as, - ) - - return PresentationPathAndEditPath( - **presentation_and_path.model_dump(), - edit_path=f"/presentation?id={id}", - ) - - async def check_if_api_request_is_valid( request: GeneratePresentationRequest, sql_session: AsyncSession = Depends(get_async_session), diff --git a/servers/fastapi/models/pptx_models.py b/servers/fastapi/models/pptx_models.py deleted file mode 100644 index 84ef55d3..00000000 --- a/servers/fastapi/models/pptx_models.py +++ /dev/null @@ -1,198 +0,0 @@ -from enum import Enum -from typing import Annotated, List, Literal, Optional, Union -from annotated_types import Len -from pydantic import BaseModel, Discriminator, field_validator -from pptx.util import Pt -from pptx.enum.text import PP_ALIGN -from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE, MSO_CONNECTOR_TYPE - - -class PptxBoxShapeEnum(Enum): - RECTANGLE = "rectangle" - CIRCLE = "circle" - - -class PptxObjectFitEnum(Enum): - CONTAIN = "contain" - COVER = "cover" - FILL = "fill" - - -class PptxSpacingModel(BaseModel): - top: int = 0 - bottom: int = 0 - left: int = 0 - right: int = 0 - - @classmethod - def all(cls, num: int): - return PptxSpacingModel(top=num, left=num, bottom=num, right=num) - - -class PptxPositionModel(BaseModel): - left: int = 0 - top: int = 0 - width: int = 0 - height: int = 0 - - @classmethod - def for_textbox(cls, left: int, top: int, width: int): - return cls(left=left, top=top, width=width, height=100) - - def to_pt_list(self) -> List[int]: - return [Pt(self.left), Pt(self.top), Pt(self.width), Pt(self.height)] - - def to_pt_xyxy(self) -> List[int]: - return [ - Pt(self.left), - Pt(self.top), - Pt(self.left + self.width), - Pt(self.top + self.height), - ] - - -class PptxFontModel(BaseModel): - name: str = "Inter" - size: int = 16 - italic: bool = False - color: str = "000000" - font_weight: Optional[int] = 400 - underline: Optional[bool] = None - strike: Optional[bool] = None - - -class PptxFillModel(BaseModel): - color: str - opacity: float = 1.0 - - -class PptxStrokeModel(BaseModel): - color: str - thickness: float - opacity: float = 1.0 - - -class PptxShadowModel(BaseModel): - radius: int - offset: int = 0 - color: str = "000000" - opacity: float = 0.5 - angle: int = 0 - - -class PptxTextRunModel(BaseModel): - text: str - font: Optional[PptxFontModel] = None - - -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 - - -class PptxObjectFitModel(BaseModel): - fit: Optional[PptxObjectFitEnum] = None - focus: Optional[ - Annotated[List[Optional[float]], Len(min_length=2, max_length=2)] - ] = None - - -class PptxPictureModel(BaseModel): - is_network: bool - path: str - - -class PptxShapeModel(BaseModel): - shape_type: Literal["textbox", "autoshape", "picture", "connector"] - - -class PptxTextBoxModel(PptxShapeModel): - shape_type: Literal["textbox"] = "textbox" - margin: Optional[PptxSpacingModel] = None - fill: Optional[PptxFillModel] = None - position: PptxPositionModel - text_wrap: bool = True - paragraphs: List[PptxParagraphModel] - - -class PptxAutoShapeBoxModel(PptxShapeModel): - shape_type: Literal["autoshape"] = "autoshape" - type: MSO_AUTO_SHAPE_TYPE = MSO_AUTO_SHAPE_TYPE.RECTANGLE - margin: Optional[PptxSpacingModel] = None - fill: Optional[PptxFillModel] = None - stroke: Optional[PptxStrokeModel] = None - shadow: Optional[PptxShadowModel] = None - position: PptxPositionModel - text_wrap: bool = True - border_radius: Optional[int] = None - paragraphs: Optional[List[PptxParagraphModel]] = None - - @field_validator('border_radius', mode='before') - @classmethod - def convert_border_radius_to_int(cls, v): - """Convert float border_radius values to int.""" - if v is None: - return None - if isinstance(v, float): - return int(round(v)) - return v - - -class PptxPictureBoxModel(PptxShapeModel): - shape_type: Literal["picture"] = "picture" - position: PptxPositionModel - margin: Optional[PptxSpacingModel] = None - clip: bool = True - opacity: Optional[float] = None - invert: bool = False - border_radius: Optional[List[int]] = None - shape: Optional[PptxBoxShapeEnum] = None - object_fit: Optional[PptxObjectFitModel] = None - picture: PptxPictureModel - - @field_validator('border_radius', mode='before') - @classmethod - def convert_border_radius_list_to_int(cls, v): - """Convert float values in border_radius list to int.""" - if v is None: - return None - if isinstance(v, list): - return [int(round(item)) if isinstance(item, float) else int(item) for item in v] - return v - - -class PptxConnectorModel(PptxShapeModel): - shape_type: Literal["connector"] = "connector" - type: MSO_CONNECTOR_TYPE = MSO_CONNECTOR_TYPE.STRAIGHT - position: PptxPositionModel - thickness: float = 0.5 - color: str = "000000" - opacity: float = 1.0 - - -# Define a discriminated union for shapes -PptxShapeUnion = Annotated[ - Union[ - PptxTextBoxModel, - PptxAutoShapeBoxModel, - PptxConnectorModel, - PptxPictureBoxModel, - ], - Discriminator("shape_type"), -] - - -class PptxSlideModel(BaseModel): - background: Optional[PptxFillModel] = None - note: Optional[str] = None - shapes: List[PptxShapeUnion] - - -class PptxPresentationModel(BaseModel): - name: Optional[str] = None - shapes: Optional[List[PptxShapeModel]] = None - slides: List[PptxSlideModel] diff --git a/servers/fastapi/presenton_backend.egg-info/PKG-INFO b/servers/fastapi/presenton_backend.egg-info/PKG-INFO index a2ae42d4..2db2187d 100644 --- a/servers/fastapi/presenton_backend.egg-info/PKG-INFO +++ b/servers/fastapi/presenton_backend.egg-info/PKG-INFO @@ -18,6 +18,5 @@ Requires-Dist: nltk>=3.9.1 Requires-Dist: openai>=1.98.0 Requires-Dist: pathvalidate>=3.3.1 Requires-Dist: pdfplumber>=0.11.7 -Requires-Dist: python-pptx>=1.0.2 Requires-Dist: sqlmodel>=0.0.24 Requires-Dist: llmai==0.1.9 diff --git a/servers/fastapi/presenton_backend.egg-info/SOURCES.txt b/servers/fastapi/presenton_backend.egg-info/SOURCES.txt index 376ca3dd..57cacb3b 100644 --- a/servers/fastapi/presenton_backend.egg-info/SOURCES.txt +++ b/servers/fastapi/presenton_backend.egg-info/SOURCES.txt @@ -49,7 +49,6 @@ models/image_prompt.py models/json_path_guide.py models/ollama_model_metadata.py models/ollama_model_status.py -models/pptx_models.py models/presentation_and_path.py models/presentation_from_template.py models/presentation_layout.py @@ -81,7 +80,6 @@ services/database.py services/document_conversion_service.py services/documents_loader.py services/export_task_service.py -services/html_to_text_runs_service.py services/icon_finder_service.py services/image_generation_service.py services/liteparse_service.py @@ -106,7 +104,6 @@ tests/test_liteparse_service.py tests/test_mcp_server.py tests/test_mem0_presentation_memory_service.py tests/test_openai_schema_support.py -tests/test_pptx_creator.py tests/test_pptx_slides_processing.py tests/test_presentation_generation_api.py tests/test_slide_to_html.py @@ -126,7 +123,6 @@ utils/get_dynamic_models.py utils/get_env.py utils/get_layout_by_name.py utils/image_provider.py -utils/image_utils.py utils/llm_client_error_handler.py utils/llm_config.py utils/llm_provider.py @@ -153,4 +149,4 @@ utils/llm_calls/generate_slide_content.py utils/llm_calls/select_slide_type_on_edit.py utils/oauth/__init__.py utils/oauth/openai_codex.py -utils/oauth/pkce.py \ No newline at end of file +utils/oauth/pkce.py diff --git a/servers/fastapi/presenton_backend.egg-info/requires.txt b/servers/fastapi/presenton_backend.egg-info/requires.txt index b7f83600..c15ba9a5 100644 --- a/servers/fastapi/presenton_backend.egg-info/requires.txt +++ b/servers/fastapi/presenton_backend.egg-info/requires.txt @@ -13,6 +13,5 @@ nltk>=3.9.1 openai>=1.98.0 pathvalidate>=3.3.1 pdfplumber>=0.11.7 -python-pptx>=1.0.2 sqlmodel>=0.0.24 llmai==0.1.9 diff --git a/servers/fastapi/pyproject.toml b/servers/fastapi/pyproject.toml index b36d123b..70f93a6e 100644 --- a/servers/fastapi/pyproject.toml +++ b/servers/fastapi/pyproject.toml @@ -23,7 +23,6 @@ dependencies = [ "openai>=1.98.0", "pathvalidate>=3.3.1", "pdfplumber>=0.11.7", - "python-pptx>=1.0.2", "sqlmodel>=0.0.24", "llmai==0.1.9", ] diff --git a/servers/fastapi/services/export_task_service.py b/servers/fastapi/services/export_task_service.py index ca31c588..5dcff9ae 100644 --- a/servers/fastapi/services/export_task_service.py +++ b/servers/fastapi/services/export_task_service.py @@ -4,7 +4,7 @@ import os import shutil import subprocess import tempfile -from typing import Mapping +from typing import Literal, Mapping from fastapi import HTTPException from pydantic import BaseModel @@ -23,6 +23,10 @@ class PptxToHtmlDocument(BaseModel): fonts_dir: str +class PresentationExportTaskResult(BaseModel): + path: str + + class ExportTaskService: def __init__(self, timeout_seconds: int = 300): self.timeout_seconds = timeout_seconds @@ -147,29 +151,24 @@ class ExportTaskService: detail="PPTX-to-HTML task completed without a valid output path", ) - async def convert_pptx_to_html( - self, pptx_path: str, get_fonts: bool = False - ) -> PptxToHtmlDocument: - self._ensure_runtime_ready() - if not os.path.isfile(pptx_path): - raise HTTPException(status_code=400, detail=f"PPTX not found: {pptx_path}") - - temp_root = get_temp_directory_env() or os.path.join(tempfile.gettempdir(), "presenton") + @staticmethod + def _create_task_paths() -> tuple[str, str, str]: + temp_root = get_temp_directory_env() or os.path.join( + tempfile.gettempdir(), "presenton" + ) os.makedirs(temp_root, exist_ok=True) temp_dir = tempfile.mkdtemp(prefix="export-task-", dir=temp_root) task_path = os.path.join(temp_dir, "export_task.json") response_path = os.path.join(temp_dir, "export_task.response.json") + return temp_dir, task_path, response_path + + async def _run_task(self, task_payload: dict, response_error_detail: str) -> dict: + self._ensure_runtime_ready() + temp_dir, task_path, response_path = self._create_task_paths() try: with open(task_path, "w", encoding="utf-8") as task_file: - json.dump( - { - "type": "pptx-to-html", - "pptx_path": pptx_path, - "get_fonts": get_fonts, - }, - task_file, - ) + json.dump(task_payload, task_file) result = await asyncio.to_thread( subprocess.run, @@ -185,7 +184,7 @@ class ExportTaskService: raise HTTPException( status_code=500, detail=( - "PPTX-to-HTML export task failed. " + "Export task failed. " f"stderr={_snippet(result.stderr)} stdout={_snippet(result.stdout)}" ), ) @@ -193,34 +192,77 @@ class ExportTaskService: if not os.path.isfile(response_path): raise HTTPException( status_code=500, - detail="PPTX-to-HTML export task did not produce a response file", + detail=response_error_detail, ) with open(response_path, "r", encoding="utf-8") as response_file: - response_data = json.load(response_file) + return json.load(response_file) + except subprocess.TimeoutExpired as exc: + raise HTTPException( + status_code=500, + detail=f"Export task timed out after {self.timeout_seconds} seconds", + ) from exc + except json.JSONDecodeError as exc: + raise HTTPException( + status_code=500, + detail="Export task produced invalid JSON output", + ) from exc + except OSError as exc: + raise HTTPException( + status_code=500, + detail=f"Failed to run export task: {exc}", + ) from exc + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + async def export_from_url( + self, + url: str, + title: str, + export_as: Literal["pdf", "pptx"], + fastapi_url: str | None = None, + ) -> PresentationExportTaskResult: + response_data = await self._run_task( + { + "type": "export", + "url": url, + "format": export_as, + "title": title, + "fastapiUrl": fastapi_url or None, + }, + "Export task did not produce a response file", + ) + + return PresentationExportTaskResult( + path=self._resolve_output_path(response_data), + ) + + async def convert_pptx_to_html( + self, pptx_path: str, get_fonts: bool = False + ) -> PptxToHtmlDocument: + if not os.path.isfile(pptx_path): + raise HTTPException(status_code=400, detail=f"PPTX not found: {pptx_path}") + + try: + response_data = await self._run_task( + { + "type": "pptx-to-html", + "pptx_path": pptx_path, + "get_fonts": get_fonts, + }, + "PPTX-to-HTML export task did not produce a response file", + ) output_path = self._resolve_output_path(response_data) with open(output_path, "r", encoding="utf-8") as output_file: output_data = json.load(output_file) return PptxToHtmlDocument(**output_data) - except subprocess.TimeoutExpired as exc: - raise HTTPException( - status_code=500, - detail=f"PPTX-to-HTML export timed out after {self.timeout_seconds} seconds", - ) from exc except json.JSONDecodeError as exc: raise HTTPException( status_code=500, detail="PPTX-to-HTML export produced invalid JSON output", ) from exc - except OSError as exc: - raise HTTPException( - status_code=500, - detail=f"Failed to run PPTX-to-HTML export task: {exc}", - ) from exc - finally: - shutil.rmtree(temp_dir, ignore_errors=True) def sys_platform() -> str: diff --git a/servers/fastapi/services/html_to_text_runs_service.py b/servers/fastapi/services/html_to_text_runs_service.py deleted file mode 100644 index 25a441a7..00000000 --- a/servers/fastapi/services/html_to_text_runs_service.py +++ /dev/null @@ -1,65 +0,0 @@ -from html.parser import HTMLParser -from typing import List, Optional - -from models.pptx_models import PptxFontModel, PptxTextRunModel - - -class InlineHTMLToRunsParser(HTMLParser): - def __init__(self, base_font: PptxFontModel): - super().__init__(convert_charrefs=True) - self.base_font = base_font - self.tag_stack: List[str] = [] - self.text_runs: List[PptxTextRunModel] = [] - - def _current_font(self) -> PptxFontModel: - font_json = self.base_font.model_dump() - is_bold = any(tag in ("strong", "b") for tag in self.tag_stack) - is_italic = any(tag in ("em", "i") for tag in self.tag_stack) - is_underline = any(tag == "u" for tag in self.tag_stack) - is_strike = any(tag in ("s", "strike", "del") for tag in self.tag_stack) - is_code = any(tag == "code" for tag in self.tag_stack) - - if is_bold: - font_json["font_weight"] = 700 - if is_italic: - font_json["italic"] = True - if is_underline: - font_json["underline"] = True - if is_strike: - font_json["strike"] = True - if is_code: - font_json["name"] = "Courier New" - - return PptxFontModel(**font_json) - - def handle_starttag(self, tag, attrs): - tag = tag.lower() - if tag == "br": - self.text_runs.append(PptxTextRunModel(text="\n")) - return - self.tag_stack.append(tag) - - def handle_endtag(self, tag): - tag = tag.lower() - for i in range(len(self.tag_stack) - 1, -1, -1): - if self.tag_stack[i] == tag: - del self.tag_stack[i] - break - - def handle_data(self, data): - if data == "": - return - self.text_runs.append(PptxTextRunModel(text=data, font=self._current_font())) - - -def parse_html_text_to_text_runs( - text: str, base_font: Optional[PptxFontModel] = None -) -> List[PptxTextRunModel]: - normalized_text = text.replace("\r\n", "\n").replace("\r", "\n") - normalized_text = normalized_text.replace("\n", "
") - - parser = InlineHTMLToRunsParser(base_font if base_font else PptxFontModel()) - parser.feed(normalized_text) - return parser.text_runs - - diff --git a/servers/fastapi/services/pptx_presentation_creator.py b/servers/fastapi/services/pptx_presentation_creator.py deleted file mode 100644 index e0f179da..00000000 --- a/servers/fastapi/services/pptx_presentation_creator.py +++ /dev/null @@ -1,632 +0,0 @@ -import os -from typing import List, Optional -from lxml import etree -from services.html_to_text_runs_service import ( - parse_html_text_to_text_runs as parse_inline_html_to_runs, -) -import tempfile -import zipfile - -from pptx import Presentation -from pptx.shapes.autoshape import Shape -from pptx.slide import Slide -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 - -from models.pptx_models import ( - PptxAutoShapeBoxModel, - PptxBoxShapeEnum, - PptxConnectorModel, - PptxFillModel, - PptxFontModel, - PptxParagraphModel, - PptxPictureBoxModel, - PptxPositionModel, - PptxPresentationModel, - PptxShadowModel, - PptxSlideModel, - PptxSpacingModel, - PptxStrokeModel, - PptxTextBoxModel, - PptxTextRunModel, -) -from utils.asset_directory_utils import get_images_directory, resolve_image_path_to_filesystem -from utils.download_helpers import download_files -from utils.get_env import get_app_data_directory_env -from utils.image_utils import ( - clip_image, - create_circle_image, - fit_image, - invert_image, - round_image_corners, - set_image_opacity, -) -import uuid - -BLANK_SLIDE_LAYOUT = 6 - - -class PptxPresentationCreator: - def __init__(self, ppt_model: PptxPresentationModel, temp_dir: str): - self._temp_dir = temp_dir - - self._ppt_model = ppt_model - self._slide_models = ppt_model.slides - - self._ppt = Presentation() - 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 - - - def fix_keynote_compatibility(self, pptx_path: str): - """Patch pptx XML for stricter parsers like Keynote.""" - PRESENTATION_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" - DRAWING_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" - REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" - NOTES_MASTER_REL_TYPE = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" - ) - - def ensure_grp_sppr_xfrm(slide_path: str): - slide_tree = etree.parse(slide_path) - slide_root = slide_tree.getroot() - grp_sppr_elements = slide_root.findall( - f".//{{{PRESENTATION_NS}}}grpSpPr" - ) - changed = False - for grp_sppr in grp_sppr_elements: - xfrm = grp_sppr.find(f"{{{DRAWING_NS}}}xfrm") - if xfrm is None: - xfrm = etree.SubElement(grp_sppr, f"{{{DRAWING_NS}}}xfrm") - etree.SubElement(xfrm, f"{{{DRAWING_NS}}}off", x="0", y="0") - etree.SubElement(xfrm, f"{{{DRAWING_NS}}}ext", cx="0", cy="0") - etree.SubElement(xfrm, f"{{{DRAWING_NS}}}chOff", x="0", y="0") - etree.SubElement(xfrm, f"{{{DRAWING_NS}}}chExt", cx="0", cy="0") - changed = True - if changed: - slide_tree.write( - slide_path, - xml_declaration=True, - encoding="UTF-8", - standalone="yes", - ) - - with tempfile.TemporaryDirectory() as temp_dir: - extract_dir = os.path.join(temp_dir, "pptx_contents") - os.makedirs(extract_dir, exist_ok=True) - with zipfile.ZipFile(pptx_path, "r") as existing_zip: - existing_zip.extractall(extract_dir) - - ppt_dir = os.path.join(extract_dir, "ppt") - slides_dir = os.path.join(ppt_dir, "slides") - if os.path.isdir(slides_dir): - for file_name in os.listdir(slides_dir): - if file_name.endswith(".xml"): - ensure_grp_sppr_xfrm(os.path.join(slides_dir, file_name)) - - rels_path = os.path.join(ppt_dir, "_rels", "presentation.xml.rels") - presentation_path = os.path.join(ppt_dir, "presentation.xml") - if os.path.exists(rels_path) and os.path.exists(presentation_path): - rels_tree = etree.parse(rels_path) - rels_root = rels_tree.getroot() - rel_tag = f"{{{PACKAGE_REL_NS}}}Relationship" - notes_master_rel = None - existing_ids = set() - for rel in rels_root.findall(rel_tag): - rel_id = rel.get("Id") - if rel_id: - existing_ids.add(rel_id) - if rel.get("Type") == NOTES_MASTER_REL_TYPE: - notes_master_rel = rel - - notes_masters_dir = os.path.join(ppt_dir, "notesMasters") - has_notes_master = ( - os.path.isdir(notes_masters_dir) - and any( - name.endswith(".xml") for name in os.listdir(notes_masters_dir) - ) - ) - - if has_notes_master and notes_master_rel is None: - next_id = 1 - while f"rId{next_id}" in existing_ids: - next_id += 1 - notes_master_rel = etree.SubElement(rels_root, rel_tag) - notes_master_rel.set("Id", f"rId{next_id}") - notes_master_rel.set("Type", NOTES_MASTER_REL_TYPE) - notes_master_rel.set( - "Target", "notesMasters/notesMaster1.xml" - ) - rels_tree.write( - rels_path, - xml_declaration=True, - encoding="UTF-8", - standalone="yes", - ) - - if has_notes_master and notes_master_rel is not None: - presentation_tree = etree.parse(presentation_path) - presentation_root = presentation_tree.getroot() - notes_master_id_lst = presentation_root.find( - f"{{{PRESENTATION_NS}}}notesMasterIdLst" - ) - if notes_master_id_lst is None: - notes_master_id_lst = etree.Element( - f"{{{PRESENTATION_NS}}}notesMasterIdLst" - ) - sld_master_id_lst = presentation_root.find( - f"{{{PRESENTATION_NS}}}sldMasterIdLst" - ) - if sld_master_id_lst is not None: - insert_index = list(presentation_root).index( - sld_master_id_lst - ) + 1 - presentation_root.insert(insert_index, notes_master_id_lst) - else: - presentation_root.insert(0, notes_master_id_lst) - if not notes_master_id_lst.findall( - f"{{{PRESENTATION_NS}}}notesMasterId" - ): - notes_master_id = etree.SubElement( - notes_master_id_lst, - f"{{{PRESENTATION_NS}}}notesMasterId", - ) - notes_master_id.set( - f"{{{REL_NS}}}id", - notes_master_rel.get("Id"), - ) - presentation_tree.write( - presentation_path, - xml_declaration=True, - encoding="UTF-8", - standalone="yes", - ) - - with zipfile.ZipFile(pptx_path, "w", zipfile.ZIP_DEFLATED) as new_zip: - for root, _, files in os.walk(extract_dir): - for file_name in files: - full_path = os.path.join(root, file_name) - archive_name = os.path.relpath(full_path, extract_dir) - new_zip.write(full_path, archive_name) - - - async def fetch_network_assets(self): - image_urls = [] - models_with_network_asset: List[PptxPictureBoxModel] = [] - - def _process_image_path(each_shape, image_path): - if not image_path.startswith("http"): - return - if "app_data/" in image_path: - relative_path = image_path.split("app_data/")[1] - app_data_dir = get_app_data_directory_env() - if app_data_dir: - each_shape.picture.path = os.path.join(app_data_dir, relative_path) - else: - each_shape.picture.path = os.path.join("/app_data", relative_path) - each_shape.picture.is_network = False - return - # Resolve HTTP URLs that contain absolute filesystem paths. - local_path = resolve_image_path_to_filesystem(image_path) - if local_path: - each_shape.picture.path = local_path - each_shape.picture.is_network = False - return - image_urls.append(image_path) - models_with_network_asset.append(each_shape) - - if self._ppt_model.shapes: - for each_shape in self._ppt_model.shapes: - if isinstance(each_shape, PptxPictureBoxModel): - _process_image_path(each_shape, each_shape.picture.path) - - for each_slide in self._slide_models: - for each_shape in each_slide.shapes: - if isinstance(each_shape, PptxPictureBoxModel): - _process_image_path(each_shape, each_shape.picture.path) - - if image_urls: - image_paths = await download_files(image_urls, self._temp_dir) - - for each_shape, each_image_path in zip( - models_with_network_asset, image_paths - ): - if each_image_path: - each_shape.picture.path = each_image_path - each_shape.picture.is_network = False - - async def create_ppt(self): - await self.fetch_network_assets() - - for slide_model in self._slide_models: - # Adding global shapes to slide - if self._ppt_model.shapes: - slide_model.shapes.append(self._ppt_model.shapes) - - self.add_and_populate_slide(slide_model) - - def set_presentation_theme(self): - slide_master = self._ppt.slide_master - slide_master_part = slide_master.part - - theme_part = slide_master_part.part_related_by(RT.THEME) - theme = fromstring(theme_part.blob) - - theme_colors = self._theme.colors.theme_color_mapping - nsmap = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"} - - for color_name, hex_value in theme_colors.items(): - if color_name: - color_element = theme.xpath( - f"a:themeElements/a:clrScheme/a:{color_name}/a:srgbClr", - namespaces=nsmap, - )[0] - color_element.set("val", hex_value.encode("utf-8")) - - theme_part._blob = tostring(theme) - - def add_and_populate_slide(self, slide_model: PptxSlideModel): - slide = self._ppt.slides.add_slide(self._ppt.slide_layouts[BLANK_SLIDE_LAYOUT]) - - if slide_model.background: - self.apply_fill_to_shape(slide.background, slide_model.background) - - if slide_model.note: - slide.notes_slide.notes_text_frame.text = slide_model.note - - for shape_model in slide_model.shapes: - model_type = type(shape_model) - - if model_type is PptxPictureBoxModel: - self.add_picture(slide, shape_model) - - elif model_type is PptxAutoShapeBoxModel: - self.add_autoshape(slide, shape_model) - - elif model_type is PptxTextBoxModel: - self.add_textbox(slide, shape_model) - - elif model_type is PptxConnectorModel: - self.add_connector(slide, shape_model) - - def add_connector(self, slide: Slide, connector_model: PptxConnectorModel): - if connector_model.thickness == 0: - return - connector_shape = slide.shapes.add_connector( - connector_model.type, *connector_model.position.to_pt_xyxy() - ) - connector_shape.line.width = Pt(connector_model.thickness) - connector_shape.line.color.rgb = RGBColor.from_string(connector_model.color) - self.set_fill_opacity(connector_shape, connector_model.opacity) - - def add_picture(self, slide: Slide, picture_model: PptxPictureBoxModel): - image_path = picture_model.picture.path - # Resolve /app_data/... to actual filesystem path. - if image_path.startswith("/app_data/"): - app_data_dir = get_app_data_directory_env() - if app_data_dir: - relative = image_path[len("/app_data/"):] - image_path = os.path.join(app_data_dir, relative) - if ( - picture_model.clip - or picture_model.border_radius - or picture_model.invert - or picture_model.opacity - or picture_model.object_fit - or picture_model.shape - ): - try: - image = Image.open(image_path) - except Exception: - print(f"Could not open image: {image_path}") - return - - image = image.convert("RGBA") - # ? Applying border radius twice to support both clip and object fit - if picture_model.border_radius: - image = round_image_corners(image, picture_model.border_radius) - if picture_model.object_fit: - image = fit_image( - image, - picture_model.position.width, - picture_model.position.height, - picture_model.object_fit, - ) - elif picture_model.clip: - image = clip_image( - image, - picture_model.position.width, - picture_model.position.height, - ) - if picture_model.border_radius: - image = round_image_corners(image, picture_model.border_radius) - if picture_model.shape == PptxBoxShapeEnum.CIRCLE: - image = create_circle_image(image) - if picture_model.invert: - image = invert_image(image) - if picture_model.opacity: - image = set_image_opacity(image, picture_model.opacity) - image_path = os.path.join(self._temp_dir, f"{uuid.uuid4()}.png") - image.save(image_path) - - margined_position = self.get_margined_position( - picture_model.position, picture_model.margin - ) - - slide.shapes.add_picture(image_path, *margined_position.to_pt_list()) - - def add_autoshape(self, slide: Slide, autoshape_box_model: PptxAutoShapeBoxModel): - position = autoshape_box_model.position - if autoshape_box_model.margin: - position = self.get_margined_position(position, autoshape_box_model.margin) - - autoshape = slide.shapes.add_shape( - autoshape_box_model.type, *position.to_pt_list() - ) - - textbox = autoshape.text_frame - textbox.word_wrap = autoshape_box_model.text_wrap - - self.apply_fill_to_shape(autoshape, autoshape_box_model.fill) - self.apply_margin_to_text_box(textbox, autoshape_box_model.margin) - self.apply_stroke_to_shape(autoshape, autoshape_box_model.stroke) - self.apply_shadow_to_shape(autoshape, autoshape_box_model.shadow) - self.apply_border_radius_to_shape(autoshape, autoshape_box_model.border_radius) - - if autoshape_box_model.paragraphs: - self.add_paragraphs(textbox, autoshape_box_model.paragraphs) - - def add_textbox(self, slide: Slide, textbox_model: PptxTextBoxModel): - position = textbox_model.position - textbox_shape = slide.shapes.add_textbox(*position.to_pt_list()) - textbox_shape.width += Pt(2) - - textbox = textbox_shape.text_frame - textbox.word_wrap = textbox_model.text_wrap - - self.apply_fill_to_shape(textbox_shape, textbox_model.fill) - self.apply_margin_to_text_box(textbox, textbox_model.margin) - self.add_paragraphs(textbox, textbox_model.paragraphs) - - def add_paragraphs( - self, textbox: TextFrame, paragraph_models: List[PptxParagraphModel] - ): - for index, paragraph_model in enumerate(paragraph_models): - paragraph = textbox.add_paragraph() if index > 0 else textbox.paragraphs[0] - self.populate_paragraph(paragraph, paragraph_model) - - def populate_paragraph( - self, paragraph: _Paragraph, paragraph_model: PptxParagraphModel - ): - 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 - - if paragraph_model.font: - self.apply_font_to_paragraph(paragraph, paragraph_model.font) - - text_runs = [] - if paragraph_model.text: - text_runs = self.parse_html_text_to_text_runs( - paragraph_model.font, paragraph_model.text - ) - elif paragraph_model.text_runs: - text_runs = paragraph_model.text_runs - - for text_run_model in text_runs: - text_run = paragraph.add_run() - self.populate_text_run(text_run, text_run_model) - - def parse_html_text_to_text_runs(self, font: Optional[PptxFontModel], text: str): - return parse_inline_html_to_runs(text, font) - - def populate_text_run(self, text_run: _Run, text_run_model: PptxTextRunModel): - text_run.text = text_run_model.text - if text_run_model.font: - self.apply_font(text_run.font, text_run_model.font) - - def apply_border_radius_to_shape(self, shape: Shape, border_radius: Optional[int]): - if not border_radius: - return - try: - normalized_border_radius = Pt(border_radius) / min( - shape.width, shape.height - ) - shape.adjustments[0] = normalized_border_radius - except Exception: - print("Could not apply border radius.") - - def apply_fill_to_shape(self, shape: Shape, fill: Optional[PptxFillModel] = None): - if not fill: - shape.fill.background() - 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 - ): - if not stroke or stroke.thickness == 0: - shape.line.fill.background() - else: - 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 - ): - # Access the XML for the shape - sp_element = shape._element - sp_pr = sp_element.xpath("p:spPr")[0] # Shape properties XML element - - nsmap = sp_pr.nsmap - - # # Remove existing shadow effects if present - effect_list = sp_pr.find("a:effectLst", namespaces=nsmap) - if effect_list: - old_outer_shadow = effect_list.find("a:outerShdw") - if old_outer_shadow: - effect_list.remove( - 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 effect_list: - effect_list = etree.SubElement( - sp_pr, f"{{{nsmap['a']}}}effectLst", 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 - # dir expects 60000ths of a degree in OOXML - angle_dir = ( - int(round((shadow.angle % 360) * 60000)) - if shadow.angle is not None - else 0 - ) - outer_shadow = etree.SubElement( - effect_list, - f"{{{nsmap['a']}}}outerShdw", - { - "blurRad": f"{Pt(shadow.radius)}", - "dir": f"{angle_dir}", - "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: - 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: - if not margin: - return position - - left = position.left + margin.left - top = position.top + margin.top - width = max(position.width - margin.left - margin.right, 0) - height = max(position.height - margin.top - margin.bottom, 0) - - return PptxPositionModel(left=left, top=top, width=width, height=height) - - def apply_margin_to_text_box( - self, text_frame: TextFrame, margin: Optional[PptxSpacingModel] - ) -> PptxPositionModel: - text_frame.margin_left = Pt(margin.left if margin else 0) - text_frame.margin_right = Pt(margin.right if margin else 0) - text_frame.margin_top = Pt(margin.top if margin else 0) - text_frame.margin_bottom = Pt(margin.bottom if margin else 0) - - def apply_spacing_to_paragraph( - self, paragraph: _Paragraph, spacing: PptxSpacingModel - ): - paragraph.space_before = Pt(spacing.top) - paragraph.space_after = Pt(spacing.bottom) - - def apply_font_to_paragraph(self, paragraph: _Paragraph, font: PptxFontModel): - self.apply_font(paragraph.font, font) - - def apply_font(self, font: Font, font_model: PptxFontModel): - font.name = font_model.name - font.color.rgb = RGBColor.from_string(font_model.color) - font.italic = font_model.italic - font.size = Pt(font_model.size) - font.bold = font_model.font_weight >= 600 - if font_model.underline is not None: - font.underline = bool(font_model.underline) - if font_model.strike is not None: - self.apply_strike_to_font(font, font_model.strike) - - def apply_strike_to_font(self, font: Font, strike: Optional[bool]): - try: - rPr = font._element - if strike is True: - rPr.set("strike", "sngStrike") - elif strike is False: - rPr.set("strike", "noStrike") - except Exception as e: - print(f"Could not apply strikethrough: {e}") - - def save(self, path: str): - self._ppt.save(path) - self.fix_keynote_compatibility(path) diff --git a/servers/fastapi/tests/test_pptx_creator.py b/servers/fastapi/tests/test_pptx_creator.py deleted file mode 100644 index 9cc92718..00000000 --- a/servers/fastapi/tests/test_pptx_creator.py +++ /dev/null @@ -1,40 +0,0 @@ -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") diff --git a/servers/fastapi/utils/export_utils.py b/servers/fastapi/utils/export_utils.py index 597212ca..c75d8b42 100644 --- a/servers/fastapi/utils/export_utils.py +++ b/servers/fastapi/utils/export_utils.py @@ -1,67 +1,47 @@ -import json import os -import aiohttp from typing import Literal +from urllib.parse import urlencode import uuid -from fastapi import HTTPException + from pathvalidate import sanitize_filename -from models.pptx_models import PptxPresentationModel from models.presentation_and_path import PresentationAndPath -from services.pptx_presentation_creator import PptxPresentationCreator -from services.temp_file_service import TEMP_FILE_SERVICE -from utils.asset_directory_utils import get_exports_directory -import uuid +from services.export_task_service import EXPORT_TASK_SERVICE + + +def _get_next_public_url() -> str: + return (os.getenv("NEXT_PUBLIC_URL") or "").strip() or "http://127.0.0.1" + + +def _get_next_public_fastapi_url() -> str | None: + value = (os.getenv("NEXT_PUBLIC_FAST_API") or "").strip() + return value or None + + +def _build_presentation_export_url(presentation_id: uuid.UUID) -> tuple[str, str | None]: + params = {"id": str(presentation_id)} + fastapi_url = _get_next_public_fastapi_url() + if fastapi_url: + params["fastapiUrl"] = fastapi_url + + return ( + f"{_get_next_public_url().rstrip('/')}/pdf-maker?{urlencode(params)}", + fastapi_url, + ) async def export_presentation( presentation_id: uuid.UUID, title: str, export_as: Literal["pptx", "pdf"] ) -> PresentationAndPath: - if export_as == "pptx": + export_url, fastapi_url = _build_presentation_export_url(presentation_id) + export_result = await EXPORT_TASK_SERVICE.export_from_url( + url=export_url, + title=sanitize_filename(title or str(uuid.uuid4())), + export_as=export_as, + fastapi_url=fastapi_url, + ) - # Get the converted PPTX model from the Next.js service - async with aiohttp.ClientSession() as session: - async with session.get( - f"http://localhost/api/presentation_to_pptx_model?id={presentation_id}" - ) as response: - if response.status != 200: - error_text = await response.text() - print(f"Failed to get PPTX model: {error_text}") - raise HTTPException( - status_code=500, - detail="Failed to convert presentation to PPTX model", - ) - pptx_model_data = await response.json() - - # Create PPTX file using the converted model - pptx_model = PptxPresentationModel(**pptx_model_data) - temp_dir = TEMP_FILE_SERVICE.create_temp_dir() - pptx_creator = PptxPresentationCreator(pptx_model, temp_dir) - await pptx_creator.create_ppt() - - export_directory = get_exports_directory() - pptx_path = os.path.join( - export_directory, - f"{sanitize_filename(title or str(uuid.uuid4()))}.pptx", - ) - pptx_creator.save(pptx_path) - - return PresentationAndPath( - presentation_id=presentation_id, - path=pptx_path, - ) - else: - async with aiohttp.ClientSession() as session: - async with session.post( - "http://localhost/api/export-as-pdf", - json={ - "id": str(presentation_id), - "title": sanitize_filename(title or str(uuid.uuid4())), - }, - ) as response: - response_json = await response.json() - - return PresentationAndPath( - presentation_id=presentation_id, - path=response_json["path"], - ) + return PresentationAndPath( + presentation_id=presentation_id, + path=export_result.path, + ) diff --git a/servers/fastapi/utils/image_utils.py b/servers/fastapi/utils/image_utils.py deleted file mode 100644 index 3e8ebae8..00000000 --- a/servers/fastapi/utils/image_utils.py +++ /dev/null @@ -1,258 +0,0 @@ -from typing import List - -from PIL import Image, ImageDraw - -from models.pptx_models import PptxObjectFitEnum, PptxObjectFitModel - - -def clip_image( - image: Image.Image, - width: int, - height: int, - focus_x: float = 50.0, - focus_y: float = 50.0, -) -> Image.Image: - img_width, img_height = image.size - - img_aspect = img_width / img_height - box_aspect = width / height - - if img_aspect > box_aspect: - new_height = height - new_width = int(new_height * img_aspect) - else: - new_width = width - new_height = int(new_width / img_aspect) - - resized_image = image.resize((new_width, new_height), Image.LANCZOS) - - # Calculate clipping position based on focus - # Convert focus percentages (0-100) to position in the resized image - focus_x = max(0.0, min(100.0, focus_x)) # Clamp to 0-100 range - focus_y = max(0.0, min(100.0, focus_y)) # Clamp to 0-100 range - - # Calculate the center point based on focus - center_x = int((new_width - width) * (focus_x / 100.0)) - center_y = int((new_height - height) * (focus_y / 100.0)) - - # Calculate clipping box - left = center_x - top = center_y - right = left + width - bottom = top + height - - clipped_image = resized_image.crop((left, top, right, bottom)) - - return clipped_image - - -def round_image_corners(image: Image.Image, radii: List[int]) -> Image.Image: - if len(radii) != 4: - raise ValueError( - "Image Border Radius - radii must contain exactly 4 values for each corner" - ) - - w, h = image.size - - # Clamp border radius to not exceed half the width or height - max_radius = min(w // 2, h // 2) - clamped_radii = [min(radius, max_radius) for radius in radii] - - # Ensure the image has an alpha channel (RGBA) - if image.mode != "RGBA": - image = image.convert("RGBA") - - # Create a mask for the rounded corners (start with fully transparent) - rounded_mask = Image.new("L", image.size, 0) - - # Create a rectangular mask (fully opaque) - rectangular_mask = Image.new("L", image.size, 255) - - # Process each corner - for i, radius in enumerate(clamped_radii): - if radius > 0: # Only process if radius is positive - # Create a circle for this radius - circle = Image.new("L", (radius * 2, radius * 2), 0) - draw = ImageDraw.Draw(circle) - draw.ellipse((0, 0, radius * 2 - 1, radius * 2 - 1), fill=255) - - # Calculate position based on corner index - if i == 0: # top-left - rounded_mask.paste(circle.crop((0, 0, radius, radius)), (0, 0)) - rectangular_mask.paste(0, (0, 0, radius, radius)) - elif i == 1: # top-right - rounded_mask.paste( - circle.crop((radius, 0, radius * 2, radius)), (w - radius, 0) - ) - rectangular_mask.paste(0, (w - radius, 0, w, radius)) - elif i == 2: # bottom-right - rounded_mask.paste( - circle.crop((radius, radius, radius * 2, radius * 2)), - (w - radius, h - radius), - ) - rectangular_mask.paste(0, (w - radius, h - radius, w, h)) - else: # bottom-left - rounded_mask.paste( - circle.crop((0, radius, radius, radius * 2)), (0, h - radius) - ) - rectangular_mask.paste(0, (0, h - radius, radius, h)) - - # Get the original alpha channel - original_alpha = image.getchannel("A") - - # Combine the rectangular mask with the rounded corners - corner_mask = Image.composite(rounded_mask, rectangular_mask, rounded_mask) - - # Combine the corner mask with the original alpha channel - final_alpha = Image.composite( - original_alpha, Image.new("L", image.size, 0), corner_mask - ) - - # Create a new image with the modified alpha channel - result = Image.new("RGBA", image.size) - result.paste(image.convert("RGB"), (0, 0)) - result.putalpha(final_alpha) - - return result - - -def invert_image(img: Image.Image) -> Image.Image: - # Get image data - data = img.getdata() - - # Process each pixel - new_data = [] - for item in data: - # Get current pixel values - r, g, b, a = item - - # Invert RGB values while preserving transparency - if a != 0: # Skip fully transparent pixels - new_data.append((255 - r, 255 - g, 255 - b, a)) - else: - new_data.append((0, 0, 0, 0)) - - # Create new image with modified data - new_img = Image.new("RGBA", img.size) - new_img.putdata(new_data) - return new_img - - -def create_circle_image( - image: Image.Image, -) -> Image.Image: - # Convert to RGBA if not already - img = image.convert("RGBA") - # Get the original image size - size = img.size - # Use the smaller dimension for the circle - circle_size = min(size) - # Create a transparent image of the same size as original - mask = Image.new("RGBA", size, color=(0, 0, 0, 0)) - draw = ImageDraw.Draw(mask) - - # Calculate center position - center_x = size[0] // 2 - center_y = size[1] // 2 - radius = circle_size // 2 - - # Create a circular mask - draw.ellipse( - ( - center_x - radius, - center_y - radius, - center_x + radius, - center_y + radius, - ), - fill=(255, 255, 255, 255), - ) - - # Apply the circular mask - result = Image.composite(img, mask, mask) - return result - - -def set_image_opacity(image: Image.Image, opacity: float) -> Image.Image: - # Clamp opacity to valid range - opacity = max(0.0, min(1.0, opacity)) - - # Convert to RGBA if not already - if image.mode != "RGBA": - image = image.convert("RGBA") - - # Get the original alpha channel - original_alpha = image.getchannel("A") - - # Create new alpha channel with adjusted opacity - new_alpha = original_alpha.point(lambda x: int(x * opacity)) - - # Create new image with modified alpha channel - result = Image.new("RGBA", image.size) - result.paste(image.convert("RGB"), (0, 0)) - result.putalpha(new_alpha) - - return result - - -def fit_image( - image: Image.Image, width: int, height: int, object_fit: PptxObjectFitModel -) -> Image.Image: - if not object_fit.fit: - return image - - img_width, img_height = image.size - img_aspect = img_width / img_height - box_aspect = width / height - - if object_fit.fit == PptxObjectFitEnum.CONTAIN: - # Scale image to fit within the box while maintaining aspect ratio - if img_aspect > box_aspect: - new_width = width - new_height = int(width / img_aspect) - else: - new_height = height - new_width = int(height * img_aspect) - resized_image = image.resize((new_width, new_height), Image.LANCZOS) - - # Use focus point for positioning if available - focus_x = 50.0 - focus_y = 50.0 - if object_fit.focus and len(object_fit.focus) == 2: - focus_x, focus_y = object_fit.focus[0], object_fit.focus[1] - - # Calculate paste position based on focus - paste_x = int((width - new_width) * (focus_x / 100.0)) - paste_y = int((height - new_height) * (focus_y / 100.0)) - - result = Image.new("RGBA", (width, height), (0, 0, 0, 0)) - result.paste(resized_image, (paste_x, paste_y)) - return result - - elif object_fit.fit == PptxObjectFitEnum.COVER: - # Scale image to cover the box while maintaining aspect ratio - if img_aspect > box_aspect: - new_height = height - new_width = int(height * img_aspect) - else: - new_width = width - new_height = int(width / img_aspect) - resized_image = image.resize((new_width, new_height), Image.LANCZOS) - - # Use focus point for positioning if available - focus_x = 50.0 - focus_y = 50.0 - if object_fit.focus and len(object_fit.focus) == 2: - focus_x, focus_y = object_fit.focus[0], object_fit.focus[1] - - # Calculate paste position based on focus - paste_x = int((new_width - width) * (focus_x / 100.0)) - paste_y = int((new_height - height) * (focus_y / 100.0)) - - # Clip the image to the box size - return resized_image.crop((paste_x, paste_y, paste_x + width, paste_y + height)) - - elif object_fit.fit == PptxObjectFitEnum.FILL: - # Stretch image to fill the box exactly - return image.resize((width, height), Image.LANCZOS) - - return image diff --git a/servers/fastapi/uv.lock b/servers/fastapi/uv.lock index 0a12daf7..c2544a5e 100644 --- a/servers/fastapi/uv.lock +++ b/servers/fastapi/uv.lock @@ -1188,23 +1188,16 @@ wheels = [ [[package]] name = "llmai" version = "0.1.9" -source = { url = "https://files.pythonhosted.org/packages/c6/86/5dcfd77b634947cd570680b13217b40bc72cd7d9e7f04cc1a52ff5f549a0/llmai-0.1.9-py3-none-any.whl" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anthropic" }, { name = "boto3" }, { name = "google-genai" }, { name = "openai" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/f9/dd/dc7cb70fb5f9b33abf457b2bded61f27189232e769badc065ca0e2d1cda2/llmai-0.1.9.tar.gz", hash = "sha256:00ee4b987dc07a65425a1296df937d7640541630fd347ca758ea1ed496880e67", size = 46798, upload-time = "2026-04-23T07:34:49.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/86/5dcfd77b634947cd570680b13217b40bc72cd7d9e7f04cc1a52ff5f549a0/llmai-0.1.9-py3-none-any.whl", hash = "sha256:dcd94502516586bbd6394fe2c9c610941ff4c19eae0f1316825435f35134cfb4" }, -] - -[package.metadata] -requires-dist = [ - { name = "anthropic", specifier = ">=0.79.0" }, - { name = "boto3", specifier = ">=1.42.89" }, - { name = "google-genai", specifier = ">=1.62.0" }, - { name = "openai", specifier = ">=2.18.0" }, + { url = "https://files.pythonhosted.org/packages/c6/86/5dcfd77b634947cd570680b13217b40bc72cd7d9e7f04cc1a52ff5f549a0/llmai-0.1.9-py3-none-any.whl", hash = "sha256:dcd94502516586bbd6394fe2c9c610941ff4c19eae0f1316825435f35134cfb4", size = 58968, upload-time = "2026-04-23T07:34:48.375Z" }, ] [[package]] @@ -1220,36 +1213,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] -[[package]] -name = "lxml" -version = "6.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/5d/3bccad330292946f97962df9d5f2d3ae129cce6e212732a781e856b91e07/lxml-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cec05be8c876f92a5aa07b01d60bbb4d11cfbdd654cad0561c0d7b5c043a61b9", size = 8526232, upload-time = "2026-04-18T04:27:40.389Z" }, - { url = "https://files.pythonhosted.org/packages/a7/51/adc8826570a112f83bb4ddb3a2ab510bbc2ccd62c1b9fe1f34fae2d90b57/lxml-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9c03e048b6ce8e77b09c734e931584894ecd58d08296804ca2d0b184c933ce50", size = 4595448, upload-time = "2026-04-18T04:27:44.208Z" }, - { url = "https://files.pythonhosted.org/packages/54/84/5a9ec07cbe1d2334a6465f863b949a520d2699a755738986dcd3b6b89e3f/lxml-6.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:942454ff253da14218f972b23dc72fa4edf6c943f37edd19cd697618b626fac5", size = 4923771, upload-time = "2026-04-18T04:32:17.402Z" }, - { url = "https://files.pythonhosted.org/packages/a7/23/851cfa33b6b38adb628e45ad51fb27105fa34b2b3ba9d1d4aa7a9428dfe0/lxml-6.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d036ee7b99d5148072ac7c9b847193decdfeac633db350363f7bce4fff108f0e", size = 5068101, upload-time = "2026-04-18T04:32:21.437Z" }, - { url = "https://files.pythonhosted.org/packages/b0/38/41bf99c2023c6b79916ba057d83e9db21d642f473cac210201222882d38b/lxml-6.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ae5d8d5427f3cc317e7950f2da7ad276df0cfa37b8de2f5658959e618ea8512", size = 5002573, upload-time = "2026-04-18T04:32:25.373Z" }, - { url = "https://files.pythonhosted.org/packages/c2/20/053aa10bdc39747e1e923ce2d45413075e84f70a136045bb09e5eaca41d3/lxml-6.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:363e47283bde87051b821826e71dde47f107e08614e1aa312ba0c5711e77738c", size = 5202816, upload-time = "2026-04-18T04:32:29.393Z" }, - { url = "https://files.pythonhosted.org/packages/9a/da/bc710fad8bf04b93baee752c192eaa2210cd3a84f969d0be7830fea55802/lxml-6.1.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:f504d861d9f2a8f94020130adac88d66de93841707a23a86244263d1e54682f5", size = 5329999, upload-time = "2026-04-18T04:32:34.019Z" }, - { url = "https://files.pythonhosted.org/packages/b3/cb/bf035dedbdf7fab49411aa52e4236f3445e98d38647d85419e6c0d2806b9/lxml-6.1.0-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:23a5dc68e08ed13331d61815c08f260f46b4a60fdd1640bbeb82cf89a9d90289", size = 4659643, upload-time = "2026-04-18T04:32:37.932Z" }, - { url = "https://files.pythonhosted.org/packages/5c/4f/22be31f33727a5e4c7b01b0a874503026e50329b259d3587e0b923cf964b/lxml-6.1.0-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f15401d8d3dbf239e23c818afc10c7207f7b95f9a307e092122b6f86dd43209a", size = 5265963, upload-time = "2026-04-18T04:32:41.881Z" }, - { url = "https://files.pythonhosted.org/packages/c8/2b/d44d0e5c79226017f4ab8c87a802ebe4f89f97e6585a8e4166dffcdd7b6e/lxml-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fcf3da95e93349e0647d48d4b36a12783105bcc74cb0c416952f9988410846a3", size = 5045444, upload-time = "2026-04-18T04:32:44.512Z" }, - { url = "https://files.pythonhosted.org/packages/d3/c3/3f034fec1594c331a6dbf9491238fdcc9d66f68cc529e109ec75b97197e1/lxml-6.1.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0d082495c5fcf426e425a6e28daaba1fcb6d8f854a4ff01effb1f1f381203eb9", size = 4712703, upload-time = "2026-04-18T04:32:47.16Z" }, - { url = "https://files.pythonhosted.org/packages/12/16/0b83fccc158218aca75a7aa33e97441df737950734246b9fffa39301603d/lxml-6.1.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e3c4f84b24a1fcba435157d111c4b755099c6ff00a3daee1ad281817de75ed11", size = 5252745, upload-time = "2026-04-18T04:32:50.427Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ee/12e6c1b39a77666c02eaa77f94a870aaf63c4ac3a497b2d52319448b01c6/lxml-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:976a6b39b1b13e8c354ad8d3f261f3a4ac6609518af91bdb5094760a08f132c4", size = 5226822, upload-time = "2026-04-18T04:32:53.437Z" }, - { url = "https://files.pythonhosted.org/packages/34/20/c7852904858b4723af01d2fc14b5d38ff57cb92f01934a127ebd9a9e51aa/lxml-6.1.0-cp311-cp311-win32.whl", hash = "sha256:857efde87d365706590847b916baff69c0bc9252dc5af030e378c9800c0b10e3", size = 3594026, upload-time = "2026-04-18T04:27:31.903Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/d60c732b56da5085175c07c74b2df4e6d181b0c9a61e1691474f06ef4b39/lxml-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:183bfb45a493081943be7ea2b5adfc2b611e1cf377cefa8b8a8be404f45ef9a7", size = 4025114, upload-time = "2026-04-18T04:27:34.077Z" }, - { url = "https://files.pythonhosted.org/packages/c2/df/c84dcc175fd690823436d15b41cb920cd5ba5e14cd8bfb00949d5903b320/lxml-6.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:19f4164243fc206d12ed3d866e80e74f5bc3627966520da1a5f97e42c32a3f39", size = 3667742, upload-time = "2026-04-18T04:27:38.45Z" }, - { url = "https://files.pythonhosted.org/packages/f2/88/55143966481409b1740a3ac669e611055f49efd68087a5ce41582325db3e/lxml-6.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:546b66c0dd1bb8d9fa89d7123e5fa19a8aff3a1f2141eb22df96112afb17b842", size = 3930134, upload-time = "2026-04-18T04:32:35.008Z" }, - { url = "https://files.pythonhosted.org/packages/b5/97/28b985c2983938d3cb696dd5501423afb90a8c3e869ef5d3c62569282c0f/lxml-6.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5cfa1a34df366d9dc0d5eaf420f4cf2bb1e1bebe1066d1c2fc28c179f8a4004c", size = 4210749, upload-time = "2026-04-18T04:36:03.626Z" }, - { url = "https://files.pythonhosted.org/packages/29/67/dfab2b7d58214921935ccea7ce9b3df9b7d46f305d12f0f532ac7cf6b804/lxml-6.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db88156fcf544cdbf0d95588051515cfdfd4c876fc66444eb98bceb5d6db76de", size = 4318463, upload-time = "2026-04-18T04:36:06.309Z" }, - { url = "https://files.pythonhosted.org/packages/32/a2/4ac7eb32a4d997dd352c32c32399aae27b3f268d440e6f9cfa405b575d2f/lxml-6.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:07f98f5496f96bf724b1e3c933c107f0cbf2745db18c03d2e13a291c3afd2635", size = 4251124, upload-time = "2026-04-18T04:36:09.056Z" }, - { url = "https://files.pythonhosted.org/packages/33/ef/d6abd850bb4822f9b720cfe36b547a558e694881010ff7d012191e8769c6/lxml-6.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4642e04449a1e164b5ff71ffd901ddb772dfabf5c9adf1b7be5dffe1212bc037", size = 4401758, upload-time = "2026-04-18T04:36:11.803Z" }, - { url = "https://files.pythonhosted.org/packages/40/44/3ee09a5b60cb44c4f2fbc1c9015cfd6ff5afc08f991cab295d3024dcbf2d/lxml-6.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7da13bb6fbadfafb474e0226a30570a3445cfd47c86296f2446dafbd77079ace", size = 3508860, upload-time = "2026-04-18T04:32:48.619Z" }, -] - [[package]] name = "mako" version = "1.3.11" @@ -1677,7 +1640,6 @@ dependencies = [ { name = "openai" }, { name = "pathvalidate" }, { name = "pdfplumber" }, - { name = "python-pptx" }, { name = "sqlmodel" }, ] @@ -1693,13 +1655,12 @@ requires-dist = [ { name = "fastembed-vectorstore", specifier = ">=0.5.2" }, { name = "fastmcp", specifier = ">=2.11.0" }, { name = "google-genai", specifier = ">=1.28.0" }, - { name = "llmai", url = "https://files.pythonhosted.org/packages/c6/86/5dcfd77b634947cd570680b13217b40bc72cd7d9e7f04cc1a52ff5f549a0/llmai-0.1.9-py3-none-any.whl" }, + { name = "llmai", specifier = "==0.1.9" }, { name = "mem0ai", extras = ["nlp"], specifier = ">=0.1.115" }, { name = "nltk", specifier = ">=3.9.1" }, { name = "openai", specifier = ">=1.98.0" }, { name = "pathvalidate", specifier = ">=3.3.1" }, { name = "pdfplumber", specifier = ">=0.11.7" }, - { name = "python-pptx", specifier = ">=1.0.2" }, { name = "sqlmodel", specifier = ">=0.0.24" }, ] @@ -2020,21 +1981,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" }, ] -[[package]] -name = "python-pptx" -version = "1.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "lxml" }, - { name = "pillow" }, - { name = "typing-extensions" }, - { name = "xlsxwriter" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/52/a9/0c0db8d37b2b8a645666f7fd8accea4c6224e013c42b1d5c17c93590cd06/python_pptx-1.0.2.tar.gz", hash = "sha256:479a8af0eaf0f0d76b6f00b0887732874ad2e3188230315290cd1f9dd9cc7095", size = 10109297, upload-time = "2024-08-07T17:33:37.772Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" }, -] - [[package]] name = "pytz" version = "2026.1.post1" @@ -2773,15 +2719,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, ] -[[package]] -name = "xlsxwriter" -version = "3.2.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940, upload-time = "2025-09-16T00:16:21.63Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" }, -] - [[package]] name = "yarl" version = "1.23.0" diff --git a/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx b/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx index 141c0a42..8ee1386c 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx +++ b/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx @@ -25,7 +25,6 @@ import { useDispatch, useSelector } from "react-redux"; import { RootState } from "@/store/store"; import { toast } from "sonner"; -import { PptxPresentationModel } from "@/types/pptx_models"; import { trackEvent, MixpanelEvent } from "@/utils/mixpanel"; import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo"; import ToolTip from "@/components/ToolTip"; @@ -181,12 +180,6 @@ const PresentationHeader = ({ titleBlurIntentRef.current = "cancel"; }; - const get_presentation_pptx_model = async (id: string): Promise => { - const response = await fetch(`/api/presentation_to_pptx_model?id=${id}`); - const pptx_model = await response.json(); - return pptx_model; - }; - const handleExportPptx = async () => { if (isStreaming) return; @@ -201,26 +194,30 @@ const PresentationHeader = ({ setIsExporting(true); // Save the presentation data before exporting await PresentationGenerationApi.updatePresentationContent(presentationData); - - const pptx_model = await get_presentation_pptx_model(presentation_id); - if (!pptx_model) { - throw new Error("Failed to get presentation PPTX model"); - } const safePptxFileName = buildSafeExportFileName( presentationData?.title, "pptx" ); const safePptxTitle = safePptxFileName.replace(/\.pptx$/i, ""); - const pptx_path = await PresentationGenerationApi.exportAsPPTX({ - ...pptx_model, - name: safePptxTitle, + const response = await fetch("/api/export-presentation", { + method: "POST", + body: JSON.stringify({ + format: "pptx", + id: presentation_id, + title: safePptxTitle, + }), }); - if (pptx_path) { - // window.open(pptx_path, '_self'); - downloadLink(pptx_path, safePptxFileName); - } else { + + if (!response.ok) { + throw new Error("Failed to export PPTX"); + } + + const { path: pptxPath } = await response.json(); + if (!pptxPath) { throw new Error("No path returned from export"); } + + downloadLink(pptxPath, safePptxFileName); } catch (error) { console.error("Export failed:", error); toast.error("Having trouble exporting!", { @@ -251,17 +248,17 @@ const PresentationHeader = ({ "pdf" ); const safePdfTitle = safePdfFileName.replace(/\.pdf$/i, ""); - const response = await fetch('/api/export-as-pdf', { - method: 'POST', + const response = await fetch("/api/export-presentation", { + method: "POST", body: JSON.stringify({ + format: "pdf", id: presentation_id, title: safePdfTitle, - }) + }), }); if (response.ok) { const { path: pdfPath } = await response.json(); - // window.open(pdfPath, '_blank'); downloadLink(pdfPath, safePdfFileName); } else { throw new Error("Failed to export PDF"); diff --git a/servers/nextjs/app/(presentation-generator)/services/api/presentation-generation.ts b/servers/nextjs/app/(presentation-generator)/services/api/presentation-generation.ts index 675c1fcf..51f8acf8 100644 --- a/servers/nextjs/app/(presentation-generator)/services/api/presentation-generation.ts +++ b/servers/nextjs/app/(presentation-generator)/services/api/presentation-generation.ts @@ -226,27 +226,4 @@ export class PresentationGenerationApi { } } - - - // EXPORT PRESENTATION - static async exportAsPPTX(presentationData: any) { - try { - const response = await fetch( - getApiUrl(`/api/v1/ppt/presentation/export/pptx`), - { - method: "POST", - headers: getHeader(), - body: JSON.stringify(presentationData), - cache: "no-cache", - } - ); - return await ApiResponseHandler.handleResponse(response, "Failed to export as PowerPoint"); - } catch (error) { - console.error("error in pptx export", error); - throw error; - } - } - - - -} \ No newline at end of file +} diff --git a/servers/nextjs/app/api/export-as-pdf/route.ts b/servers/nextjs/app/api/export-presentation/route.ts similarity index 53% rename from servers/nextjs/app/api/export-as-pdf/route.ts rename to servers/nextjs/app/api/export-presentation/route.ts index 0cfee485..5a0c0648 100644 --- a/servers/nextjs/app/api/export-as-pdf/route.ts +++ b/servers/nextjs/app/api/export-presentation/route.ts @@ -1,12 +1,18 @@ -import { NextResponse, NextRequest } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { + BundledPresentationExportFormat, bundledExportPackageAvailable, - runBundledPdfExport, -} from "@/lib/run-bundled-pdf-export"; + runBundledPresentationExport, +} from "@/lib/run-bundled-presentation-export"; + +function isValidFormat(value: unknown): value is BundledPresentationExportFormat { + return value === "pdf" || value === "pptx"; +} export async function POST(req: NextRequest) { - const { id, title } = await req.json(); + const { format, id, title } = await req.json(); + if (!id) { return NextResponse.json( { error: "Missing Presentation ID" }, @@ -14,6 +20,13 @@ export async function POST(req: NextRequest) { ); } + if (!isValidFormat(format)) { + return NextResponse.json( + { error: "Invalid export format" }, + { status: 400 } + ); + } + try { if (!(await bundledExportPackageAvailable())) { throw new Error( @@ -21,17 +34,19 @@ export async function POST(req: NextRequest) { ); } - const { path: outPath } = await runBundledPdfExport({ + const { path: outPath } = await runBundledPresentationExport({ + format, presentationId: id, title, }); + return NextResponse.json({ success: true, path: outPath, }); } catch (e) { const message = e instanceof Error ? e.message : String(e); - console.error("[export-as-pdf]", message); + console.error(`[export-presentation:${format}]`, message); return NextResponse.json( { error: message, success: false }, { status: 500 } diff --git a/servers/nextjs/app/api/presentation_to_pptx_model/route.ts b/servers/nextjs/app/api/presentation_to_pptx_model/route.ts deleted file mode 100644 index 7e126b6d..00000000 --- a/servers/nextjs/app/api/presentation_to_pptx_model/route.ts +++ /dev/null @@ -1,1228 +0,0 @@ -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 { 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 sharp from "sharp"; - -interface GetAllChildElementsAttributesArgs { - element: ElementHandle; - rootRect?: { - left: number; - top: number; - width: number; - height: number; - } | null; - depth?: number; - inheritedFont?: ElementAttributes["font"]; - inheritedBackground?: ElementAttributes["background"]; - inheritedBorderRadius?: number[]; - inheritedZIndex?: number; - inheritedOpacity?: number; - screenshotsDir: string; -} - -export async function GET(request: NextRequest) { - // This route requires server-side Puppeteer execution and is unavailable in static/edge builds. - const isStaticMode = - process.env.IS_STATIC_EXPORT === "true" || - process.env.NEXT_RUNTIME === "edge"; - - if (isStaticMode) { - return NextResponse.json( - { - error: "This API route requires a server runtime and is not available in static export mode", - message: "This functionality is only available in server deployments with Puppeteer support" - }, - { status: 501 } - ); - } - - // Full functionality for development/Docker - let browser: Browser | null = null; - let page: Page | null = null; - - try { - const id = await getPresentationId(request); - [browser, page] = await getBrowserAndPage(id); - const screenshotsDir = getScreenshotsDir(); - - 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); - const presentation_pptx_model: PptxPresentationModel = { - slides: slides_pptx_models, - }; - - await closeBrowserAndPage(browser, page); - - return NextResponse.json(presentation_pptx_model); - } catch (error: any) { - console.error(error); - await closeBrowserAndPage(browser, page); - if (error instanceof ApiError) { - return NextResponse.json(error, { status: 400 }); - } - return NextResponse.json( - { detail: `Internal server error: ${error.message}` }, - { status: 500 } - ); - } -} - -async function getPresentationId(request: NextRequest) { - const id = request.nextUrl.searchParams.get("id"); - if (!id) { - throw new ApiError("Presentation ID not found"); - } - return id; -} - -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", - "--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, deviceScaleFactor: 1 }); - page.setDefaultNavigationTimeout(300000); - page.setDefaultTimeout(300000); - await page.goto(`http://localhost/pdf-maker?id=${id}`, { - waitUntil: "networkidle0", - timeout: 300000, - }); - return [browser, page]; -} - -async function closeBrowserAndPage(browser: Browser | null, page: Page | null) { - await page?.close(); - await browser?.close(); -} - -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"); - } - 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[] -) { - 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.element = undefined; - } - } - slideAttributes.speakerNote = speakerNotes[index]; - } -} - -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") { - 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(); - - const hideAllExcept = (targetElement: Element) => { - const allElements = document.querySelectorAll("*"); - - 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; - } - - (elem as HTMLElement).style.opacity = "0"; - }); - }; - - hideAllExcept(el); - - (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, - }); - if (!screenshot) { - throw new ApiError("Failed to screenshot element"); - } - - await element.element?.evaluate((el) => { - if ((el as any).__restoreStyles) { - (el as any).__restoreStyles(); - } - }); - - return screenshotPath; -} - -const convertSvgToPng = async (element_attibutes: ElementAttributes) => { - const svgHtml = - (await element_attibutes.element?.evaluate((el) => { - // Apply font color - const fontColor = window.getComputedStyle(el).color; - (el as HTMLElement).style.color = fontColor; - - 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") - .toBuffer(); - return pngBuffer; -}; - -async function getSlidesAttributes( - slides: ElementHandle[], - screenshotsDir: string -): Promise { - const slideAttributes = await Promise.all( - 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); - const slides = await slides_wrapper.$$(":scope > div > div"); - return { slides, speakerNotes }; -} - -async function getSlidesWrapper(page: Page): Promise> { - const slides_wrapper = await page.$("#presentation-slides-wrapper"); - if (!slides_wrapper) { - throw new ApiError("Presentation slides not found"); - } - return slides_wrapper; -} - -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") || "" - ); - }); -} - -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; - inheritedBackground = rootAttributes.background; - inheritedZIndex = rootAttributes.zIndex; - inheritedOpacity = rootAttributes.opacity; - 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 > *"); - - 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) - ) { - continue; - } - - if ( - inheritedFont && - !attributes.font && - attributes.innerText && - attributes.innerText.trim().length > 0 - ) { - attributes.font = inheritedFont; - } - if (inheritedBackground && !attributes.background && attributes.shadow) { - attributes.background = inheritedBackground; - } - if (inheritedBorderRadius && !attributes.borderRadius) { - attributes.borderRadius = inheritedBorderRadius; - } - if (inheritedZIndex !== undefined && attributes.zIndex === 0) { - attributes.zIndex = inheritedZIndex; - } - if ( - inheritedOpacity !== undefined && - (attributes.opacity === undefined || attributes.opacity === 1) - ) { - attributes.opacity = inheritedOpacity; - } - - 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, - width: attributes.position.width, - height: attributes.position.height, - }; - } - - // 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 - ) { - continue; - } - - // If element is paragraph and contains only inline formatting tags, don't go deeper - if (attributes.tagName === "p") { - const innerElementTagNames = await childElementHandle.evaluate((el) => { - 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) - ); - - if (innerElementTagNames.length > 0 && hasOnlyAllowedInlineTags) { - attributes.innerText = await childElementHandle.evaluate((el) => { - return el.innerHTML; - }); - allResults.push({ attributes, depth }); - continue; - } - } - - if ( - attributes.tagName === "svg" || - attributes.tagName === "canvas" || - attributes.tagName === "table" - ) { - attributes.should_screenshot = true; - attributes.element = childElementHandle; - } - - 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") { - continue; - } - - const childResults = await getAllChildElementsAttributes({ - element: childElementHandle, - rootRect: rootRect, - depth: depth + 1, - inheritedFont: attributes.font || inheritedFont, - inheritedBackground: attributes.background || inheritedBackground, - inheritedBorderRadius: attributes.borderRadius || inheritedBorderRadius, - inheritedZIndex: attributes.zIndex || inheritedZIndex, - inheritedOpacity: attributes.opacity || inheritedOpacity, - screenshotsDir, - }); - 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 && - attributes.position.left === 0 && - attributes.position.top === 0 && - attributes.position.width === rootRect!.width && - attributes.position.height === rootRect!.height - ); - }); - - const rootBackgroundCandidates = elementsWithRootPosition - .filter(({ attributes }) => { - return Boolean(attributes.background && attributes.background.color); - }) - .sort((a, b) => { - const zIndexA = a.attributes.zIndex || 0; - const zIndexB = b.attributes.zIndex || 0; - - // Prefer deeper nodes when z-index is tied so nested full-size backgrounds - // can override outer wrappers. - if (zIndexA === zIndexB) { - return b.depth - a.depth; - } - - // Prefer lower z-index candidates for slide background. - return zIndexA - zIndexB; - }); - - if (rootBackgroundCandidates.length > 0) { - backgroundColor = rootBackgroundCandidates[0].attributes.background?.color; - } - } - - 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 hasVisualProperties = - hasBackground || hasBorder || hasShadow || hasText; - const hasSpecialContent = hasImage || isSvg || isCanvas || isTable; - - return (hasVisualProperties && !occupiesRoot) || hasSpecialContent; - }) - : allResults; - - 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 a.depth - b.depth; - } - - return zIndexB - zIndexA; - }) - .map(({ attributes }) => { - if ( - attributes.shadow && - attributes.shadow.color && - (!attributes.background || !attributes.background.color) && - backgroundColor - ) { - attributes.background = { - color: backgroundColor, - opacity: undefined, - }; - } - return attributes; - }); - - return { - elements: sortedElements, - backgroundColor, - }; - } else { - return { - elements: filteredResults.map(({ attributes }) => attributes), - backgroundColor, - }; - } -} - -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)") { - return { hex: undefined, opacity: undefined }; - } - - if (color.startsWith("rgba(") || color.startsWith("hsla(")) { - const match = color.match(/rgba?\(([^)]+)\)|hsla?\(([^)]+)\)/); - if (match) { - const values = match[1] || match[2]; - const parts = values.split(",").map((part) => part.trim()); - - if (parts.length >= 4) { - const opacity = parseFloat(parts[3]); - const rgbColor = color - .replace(/rgba?\(|hsla?\(|\)/g, "") - .split(",") - .slice(0, 3) - .join(","); - const rgbString = color.startsWith("rgba") - ? `rgb(${rgbColor})` - : `hsl(${rgbColor})`; - - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (ctx) { - ctx.fillStyle = rgbString; - const hexColor = ctx.fillStyle; - const hex = hexColor.startsWith("#") - ? hexColor.substring(1) - : hexColor; - const result = { - hex, - opacity: isNaN(opacity) ? undefined : opacity, - }; - - return result; - } - } - } - } - - if (color.startsWith("rgb(") || color.startsWith("hsl(")) { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (ctx) { - ctx.fillStyle = color; - const hexColor = ctx.fillStyle; - const hex = hexColor.startsWith("#") - ? hexColor.substring(1) - : hexColor; - return { hex, opacity: undefined }; - } - } - - if (color.startsWith("#")) { - const hex = color.substring(1); - return { hex, opacity: undefined }; - } - - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (!ctx) return { hex: color, opacity: undefined }; - - ctx.fillStyle = color; - const hexColor = ctx.fillStyle; - const hex = hexColor.startsWith("#") ? hexColor.substring(1) : hexColor; - const result = { hex, opacity: undefined }; - - return result; - } - - function hasOnlyTextNodes(el: Element): boolean { - const children = el.childNodes; - for (let i = 0; i < children.length; i++) { - const child = children[i]; - if (child.nodeType === Node.ELEMENT_NODE) { - return false; - } - } - return true; - } - - function parsePosition(el: Element) { - const rect = el.getBoundingClientRect(); - return { - left: isFinite(rect.left) ? rect.left : 0, - top: isFinite(rect.top) ? rect.top : 0, - width: isFinite(rect.width) ? rect.width : 0, - height: isFinite(rect.height) ? rect.height : 0, - }; - } - - function parseBackground(computedStyles: CSSStyleDeclaration) { - const backgroundColorResult = colorToHex(computedStyles.backgroundColor); - - 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 parseBackgroundImage(computedStyles: CSSStyleDeclaration) { - const backgroundImage = computedStyles.backgroundImage; - - if (!backgroundImage || backgroundImage === "none") { - return undefined; - } - - // Extract URL from background-image style - const urlMatch = backgroundImage.match(/url\(['"]?([^'"]+)['"]?\)/); - if (urlMatch && urlMatch[1]) { - return urlMatch[1]; - } - - return undefined; - } - - function parseBorder(computedStyles: CSSStyleDeclaration) { - const borderColorResult = colorToHex(computedStyles.borderColor); - const borderWidth = parseFloat(computedStyles.borderWidth); - - 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) { - const boxShadow = computedStyles.boxShadow; - if (boxShadow !== "none") { - } - let shadow: { - offset?: [number, number]; - color?: string; - opacity?: number; - radius?: number; - angle?: number; - spread?: number; - inset?: boolean; - } = {}; - - if (boxShadow && boxShadow !== "none") { - const shadows: string[] = []; - let currentShadow = ""; - let parenCount = 0; - - for (let i = 0; i < boxShadow.length; i++) { - const char = boxShadow[i]; - if (char === "(") { - parenCount++; - } else if (char === ")") { - parenCount--; - } else if (char === "," && parenCount === 0) { - shadows.push(currentShadow.trim()); - currentShadow = ""; - continue; - } - currentShadow += char; - } - - if (currentShadow.trim()) { - shadows.push(currentShadow.trim()); - } - - let selectedShadow = ""; - let bestShadowScore = -1; - - for (let i = 0; i < shadows.length; i++) { - const shadowStr = shadows[i]; - - const shadowParts = shadowStr.split(" "); - const numericParts: number[] = []; - const colorParts: string[] = []; - let isInset = false; - let currentColor = ""; - let inColorFunction = false; - - for (let j = 0; j < shadowParts.length; j++) { - const part = shadowParts[j]; - const trimmedPart = part.trim(); - if (trimmedPart === "") continue; - - if (trimmedPart.toLowerCase() === "inset") { - isInset = true; - continue; - } - - if (trimmedPart.match(/^(rgba?|hsla?)\s*\(/i)) { - inColorFunction = true; - currentColor = trimmedPart; - continue; - } - - if (inColorFunction) { - currentColor += " " + trimmedPart; - - const openParens = (currentColor.match(/\(/g) || []).length; - const closeParens = (currentColor.match(/\)/g) || []).length; - - if (openParens <= closeParens) { - colorParts.push(currentColor); - currentColor = ""; - inColorFunction = false; - } - continue; - } - - const numericValue = parseFloat(trimmedPart); - if (!isNaN(numericValue)) { - numericParts.push(numericValue); - } else { - colorParts.push(trimmedPart); - } - } - - let hasVisibleColor = false; - if (colorParts.length > 0) { - const shadowColor = colorParts.join(" "); - const colorResult = colorToHex(shadowColor); - hasVisibleColor = !!( - colorResult.hex && - colorResult.hex !== "000000" && - colorResult.opacity !== 0 - ); - } - - const hasNonZeroValues = numericParts.some((value) => value !== 0); - - let shadowScore = 0; - if (hasNonZeroValues) { - shadowScore += numericParts.filter((value) => value !== 0).length; - } - if (hasVisibleColor) { - shadowScore += 2; - } - - if ( - (hasNonZeroValues || hasVisibleColor) && - shadowScore > bestShadowScore - ) { - selectedShadow = shadowStr; - bestShadowScore = shadowScore; - } - } - - if (!selectedShadow && shadows.length > 0) { - selectedShadow = shadows[0]; - } - - if (selectedShadow) { - const shadowParts = selectedShadow.split(" "); - const numericParts: number[] = []; - const colorParts: string[] = []; - let isInset = false; - let currentColor = ""; - let inColorFunction = false; - - for (let i = 0; i < shadowParts.length; i++) { - const part = shadowParts[i]; - const trimmedPart = part.trim(); - if (trimmedPart === "") continue; - - if (trimmedPart.toLowerCase() === "inset") { - isInset = true; - continue; - } - - if (trimmedPart.match(/^(rgba?|hsla?)\s*\(/i)) { - inColorFunction = true; - currentColor = trimmedPart; - continue; - } - - if (inColorFunction) { - currentColor += " " + trimmedPart; - - const openParens = (currentColor.match(/\(/g) || []).length; - const closeParens = (currentColor.match(/\)/g) || []).length; - - if (openParens <= closeParens) { - colorParts.push(currentColor); - currentColor = ""; - inColorFunction = false; - } - continue; - } - - const numericValue = parseFloat(trimmedPart); - if (!isNaN(numericValue)) { - numericParts.push(numericValue); - } else { - colorParts.push(trimmedPart); - } - } - - if (numericParts.length >= 2) { - const offsetX = numericParts[0]; - const offsetY = numericParts[1]; - const blurRadius = numericParts.length >= 3 ? numericParts[2] : 0; - const spreadRadius = numericParts.length >= 4 ? numericParts[3] : 0; - - // Only create shadow if color is present - if (colorParts.length > 0) { - const shadowColor = colorParts.join(" "); - const shadowColorResult = colorToHex(shadowColor); - - 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; - } - - function parseFont(computedStyles: CSSStyleDeclaration) { - const fontSize = parseFloat(computedStyles.fontSize); - const fontWeight = parseInt(computedStyles.fontWeight); - const fontColorResult = colorToHex(computedStyles.color); - const fontFamily = computedStyles.fontFamily; - const fontStyle = computedStyles.fontStyle; - - let fontName = undefined; - if (fontFamily !== "initial") { - const firstFont = fontFamily.split(",")[0].trim().replace(/['"]/g, ""); - fontName = firstFont; - } - - 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) { - const lineHeight = computedStyles.lineHeight; - const innerText = el.textContent || ""; - - const htmlEl = el as HTMLElement; - - const fontSize = parseFloat(computedStyles.fontSize); - const computedLineHeight = parseFloat(computedStyles.lineHeight); - - const singleLineHeight = !isNaN(computedLineHeight) - ? computedLineHeight - : fontSize * 1.2; - - const hasExplicitLineBreaks = - innerText.includes("\n") || - innerText.includes("\r") || - innerText.includes("\r\n"); - const hasTextWrapping = htmlEl.offsetHeight > singleLineHeight * 2; - const hasOverflow = htmlEl.scrollHeight > htmlEl.clientHeight; - - const isMultiline = - hasExplicitLineBreaks || hasTextWrapping || hasOverflow; - - 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); - const marginLeft = parseFloat(computedStyles.marginLeft); - const marginRight = parseFloat(computedStyles.marginRight); - const marginObj = { - top: isNaN(marginTop) ? undefined : marginTop, - bottom: isNaN(marginBottom) ? undefined : marginBottom, - left: isNaN(marginLeft) ? undefined : marginLeft, - right: isNaN(marginRight) ? undefined : marginRight, - }; - - return marginObj.top === 0 && - marginObj.bottom === 0 && - marginObj.left === 0 && - marginObj.right === 0 - ? undefined - : marginObj; - } - - function parsePadding(computedStyles: CSSStyleDeclaration) { - const paddingTop = parseFloat(computedStyles.paddingTop); - const paddingBottom = parseFloat(computedStyles.paddingBottom); - const paddingLeft = parseFloat(computedStyles.paddingLeft); - const paddingRight = parseFloat(computedStyles.paddingRight); - const paddingObj = { - top: isNaN(paddingTop) ? undefined : paddingTop, - bottom: isNaN(paddingBottom) ? undefined : paddingBottom, - left: isNaN(paddingLeft) ? undefined : paddingLeft, - right: isNaN(paddingRight) ? undefined : paddingRight, - }; - - return paddingObj.top === 0 && - paddingObj.bottom === 0 && - paddingObj.left === 0 && - paddingObj.right === 0 - ? undefined - : paddingObj; - } - - function parseBorderRadius( - computedStyles: CSSStyleDeclaration, - el: Element - ) { - const borderRadius = computedStyles.borderRadius; - let borderRadiusValue; - - if (borderRadius && borderRadius !== "0px") { - const radiusParts = borderRadius - .split(" ") - .map((part) => parseFloat(part)); - if (radiusParts.length === 1) { - borderRadiusValue = [ - radiusParts[0], - radiusParts[0], - radiusParts[0], - radiusParts[0], - ]; - } else if (radiusParts.length === 2) { - borderRadiusValue = [ - radiusParts[0], - radiusParts[1], - radiusParts[0], - radiusParts[1], - ]; - } else if (radiusParts.length === 3) { - borderRadiusValue = [ - radiusParts[0], - radiusParts[1], - radiusParts[2], - radiusParts[1], - ]; - } else if (radiusParts.length === 4) { - borderRadiusValue = radiusParts; - } - - // Clamp border radius values to be between 0 and half the width/height - if (borderRadiusValue) { - 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 - const maxRadius = - index === 0 || index === 2 ? maxRadiusX : maxRadiusY; - return Math.max(0, Math.min(radius, maxRadius)); - }); - } - } - - return borderRadiusValue; - } - - function parseShape(el: Element, borderRadiusValue: number[] | undefined) { - if (el.tagName.toLowerCase() === "img") { - return borderRadiusValue && - borderRadiusValue.length === 4 && - borderRadiusValue.every((radius: number) => radius === 50) - ? "circle" - : "rectangle"; - } - return undefined; - } - - function parseFilters(computedStyles: CSSStyleDeclaration) { - const filter = computedStyles.filter; - if (!filter || filter === "none") { - return undefined; - } - - const filters: { - invert?: number; - brightness?: number; - contrast?: number; - saturate?: number; - hueRotate?: number; - blur?: number; - grayscale?: number; - sepia?: number; - opacity?: number; - } = {}; - - // Parse filter functions - const filterFunctions = filter.match(/[a-zA-Z]+\([^)]*\)/g); - if (filterFunctions) { - filterFunctions.forEach((func) => { - const match = func.match(/([a-zA-Z]+)\(([^)]*)\)/); - if (match) { - const filterType = match[1]; - const value = parseFloat(match[2]); - - if (!isNaN(value)) { - switch (filterType) { - case "invert": - filters.invert = value; - break; - case "brightness": - filters.brightness = value; - break; - case "contrast": - filters.contrast = value; - break; - case "saturate": - filters.saturate = value; - break; - case "hue-rotate": - filters.hueRotate = value; - break; - case "blur": - filters.blur = value; - break; - case "grayscale": - filters.grayscale = value; - break; - case "sepia": - filters.sepia = value; - break; - case "opacity": - filters.opacity = value; - break; - } - } - } - }); - } - - // Return undefined if no filters were parsed - return Object.keys(filters).length > 0 ? filters : undefined; - } - - function parseElementAttributes(el: Element) { - let tagName = el.tagName.toLowerCase(); - - const computedStyles = window.getComputedStyle(el); - - const position = parsePosition(el); - - const shadow = parseShadow(computedStyles); - - const background = parseBackground(computedStyles); - - const border = parseBorder(computedStyles); - - const font = parseFont(computedStyles); - - const lineHeight = parseLineHeight(computedStyles, el); - - const margin = parseMargin(computedStyles); - - const padding = parsePadding(computedStyles); - - const innerText = hasOnlyTextNodes(el) - ? el.textContent || undefined - : undefined; - - const zIndex = parseInt(computedStyles.zIndex); - const zIndexValue = isNaN(zIndex) ? 0 : zIndex; - - const textAlign = computedStyles.textAlign as - | "left" - | "center" - | "right" - | "justify"; - const objectFit = computedStyles.objectFit as - | "contain" - | "cover" - | "fill" - | undefined; - - const parsedBackgroundImage = parseBackgroundImage(computedStyles); - const imageSrc = (el as HTMLImageElement).src || parsedBackgroundImage; - - const borderRadiusValue = parseBorderRadius(computedStyles, el); - - const shape = parseShape(el, borderRadiusValue) as - | "rectangle" - | "circle" - | undefined; - - const textWrap = computedStyles.whiteSpace !== "nowrap"; - - const filters = parseFilters(computedStyles); - - const opacity = parseFloat(computedStyles.opacity); - const elementOpacity = isNaN(opacity) ? undefined : opacity; - - return { - tagName: tagName, - id: el.id, - className: - el.className && typeof el.className === "string" - ? el.className - : el.className - ? el.className.toString() - : undefined, - innerText: innerText, - opacity: elementOpacity, - background: background, - border: border, - shadow: shadow, - font: font, - position: position, - margin: margin, - padding: padding, - zIndex: zIndexValue, - textAlign: textAlign !== "left" ? textAlign : undefined, - lineHeight: lineHeight, - borderRadius: borderRadiusValue, - imageSrc: imageSrc, - objectFit: objectFit, - clip: false, - overlay: undefined, - shape: shape, - connectorType: undefined, - textWrap: textWrap, - should_screenshot: false, - element: undefined, - filters: filters, - }; - } - - return parseElementAttributes(el); - }); - return attributes; -} diff --git a/servers/nextjs/app/api/template/route.ts b/servers/nextjs/app/api/template/route.ts index 722e6da9..5c439e44 100644 --- a/servers/nextjs/app/api/template/route.ts +++ b/servers/nextjs/app/api/template/route.ts @@ -1,5 +1,90 @@ import { NextResponse } from "next/server"; -import puppeteer from "puppeteer"; +import { validate as uuidValidate } from "uuid"; + +import { getSchemaByTemplateId, getSettingsByTemplateId } from "@/app/presentation-templates"; +import { compileTemplateSchema } from "@/lib/compile-template-schema"; + +type CustomTemplateLayoutsResponse = { + layouts: Array<{ + layout_code: string; + layout_id: string; + layout_name: string; + template: string; + }>; + template?: { + description?: string | null; + id: string; + name?: string | null; + } | null; +}; + +function getFastApiBaseUrl(): string { + return ( + process.env.FAST_API_INTERNAL_URL?.trim() || + process.env.NEXT_PUBLIC_FAST_API?.trim() || + "http://127.0.0.1:8000" + ); +} + +function isCustomTemplateId(groupName: string): boolean { + return groupName.startsWith("custom-") || uuidValidate(groupName); +} + +async function getCustomTemplateResponse(groupName: string) { + const templateId = groupName.startsWith("custom-") + ? groupName.slice("custom-".length) + : groupName; + const response = await fetch( + `${getFastApiBaseUrl()}/api/v1/ppt/template/${templateId}/layouts`, + { + cache: "no-store", + } + ); + + if (!response.ok) { + throw new Error(`Failed to fetch template data. HTTP ${response.status}`); + } + + const data = (await response.json()) as CustomTemplateLayoutsResponse; + return { + name: data.template?.name || groupName, + ordered: false, + slides: data.layouts + .map((layout) => { + const compiledLayout = compileTemplateSchema(layout.layout_code); + if (!compiledLayout) { + return null; + } + + return { + description: compiledLayout.layoutDescription, + id: `custom-${templateId}:${compiledLayout.layoutId}`, + json_schema: compiledLayout.schemaJSON, + name: compiledLayout.layoutName, + }; + }) + .filter( + ( + layout + ): layout is { + description: string; + id: string; + json_schema: unknown; + name: string; + } => layout !== null + ), + }; +} + +function getBuiltInTemplateResponse(groupName: string) { + const settings = getSettingsByTemplateId(groupName); + + return { + name: groupName, + ordered: settings?.ordered ?? false, + slides: getSchemaByTemplateId(groupName), + }; +} export async function GET(request: Request) { const { searchParams } = new URL(request.url); @@ -9,78 +94,17 @@ export async function GET(request: Request) { return NextResponse.json({ error: "Missing group name" }, { status: 400 }); } - const schemaPageUrl = `http://localhost/schema?group=${encodeURIComponent( - groupName - )}`; - - let browser; try { - 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", - "--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(schemaPageUrl, { - waitUntil: "networkidle0", - timeout: 300000, - }); - - await page.waitForSelector("[data-layouts]", { timeout: 300000 }); - await page.waitForSelector("[data-settings]", { timeout: 300000 }); - - const { dataLayouts, dataGroupSettings } = await page.$eval( - "[data-layouts]", - (el) => ({ - dataLayouts: el.getAttribute("data-layouts"), - dataGroupSettings: el.getAttribute("data-settings"), - }) - ); - - let slides, groupSettings; - try { - slides = JSON.parse(dataLayouts || "[]"); - } catch (e) { - slides = []; - } - try { - groupSettings = JSON.parse(dataGroupSettings || "null"); - } catch (e) { - groupSettings = null; - } - - 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, - })), - }; + const response = isCustomTemplateId(groupName) + ? await getCustomTemplateResponse(groupName) + : getBuiltInTemplateResponse(groupName); return NextResponse.json(response); - } catch (err) { + } catch (error) { + console.error("[api/template]", error); return NextResponse.json( - { error: "Failed to fetch or parse client page" }, + { error: "Failed to fetch template data" }, { status: 500 } ); - } finally { - if (browser) await browser.close(); } } diff --git a/servers/nextjs/lib/compile-template-schema.ts b/servers/nextjs/lib/compile-template-schema.ts new file mode 100644 index 00000000..7fecd4c9 --- /dev/null +++ b/servers/nextjs/lib/compile-template-schema.ts @@ -0,0 +1,402 @@ +import { parse } from "@babel/parser"; +import * as t from "@babel/types"; +import * as z from "zod"; + +export type CompiledTemplateSchema = { + layoutDescription: string; + layoutId: string; + layoutName: string; + schemaJSON: unknown; +}; + +type ExtractedDeclaration = { + init: t.Expression; + initSource: string; + name: string; + order: number; +}; + +const DANGEROUS_MEMBER_NAMES = new Set([ + "__defineGetter__", + "__defineSetter__", + "__lookupGetter__", + "__lookupSetter__", + "__proto__", + "apply", + "bind", + "call", + "constructor", + "eval", + "prototype", +]); + +function normalizeHardcodedBackendUrlsInCode(layoutCode: string): string { + return layoutCode.replace( + /https?:\/\/(?:127\.0\.0\.1|localhost|0\.0\.0\.0):(?:8000|5000)(?=\/(?:app_data|static)\/)/g, + "" + ); +} + +function unwrapExpression(node: t.Expression): t.Expression { + if ( + t.isParenthesizedExpression(node) || + t.isTSAsExpression(node) || + t.isTSTypeAssertion(node) || + t.isTSNonNullExpression(node) + ) { + return unwrapExpression(node.expression as t.Expression); + } + + return node; +} + +function getRootIdentifier(node: t.Expression): string | null { + const expression = unwrapExpression(node); + + if (t.isIdentifier(expression)) { + return expression.name; + } + + if (t.isMemberExpression(expression)) { + return getRootIdentifier(expression.object as t.Expression); + } + + if (t.isCallExpression(expression)) { + return getRootIdentifier(expression.callee as t.Expression); + } + + return null; +} + +function getStaticStringValue(node: t.Expression | null | undefined): string | null { + if (!node) { + return null; + } + + const expression = unwrapExpression(node); + + if (t.isStringLiteral(expression)) { + return expression.value; + } + + if (t.isTemplateLiteral(expression) && expression.expressions.length === 0) { + return expression.quasis + .map((quasi) => quasi.value.cooked ?? quasi.value.raw ?? "") + .join(""); + } + + return null; +} + +function extractTopLevelDeclarations(source: string): Map { + const program = parse(source, { + plugins: ["jsx", "typescript"], + sourceType: "module", + }).program; + + const declarations = new Map(); + let order = 0; + + for (const statement of program.body) { + const declaration = t.isExportNamedDeclaration(statement) + ? statement.declaration + : statement; + + if (!declaration || !t.isVariableDeclaration(declaration)) { + continue; + } + + for (const declarator of declaration.declarations) { + if (!t.isIdentifier(declarator.id) || !declarator.init) { + continue; + } + + declarations.set(declarator.id.name, { + init: unwrapExpression(declarator.init as t.Expression), + initSource: source.slice(declarator.init.start ?? 0, declarator.init.end ?? 0), + name: declarator.id.name, + order: order++, + }); + } + } + + return declarations; +} + +function readStringDeclaration( + declarations: Map, + name: string +): string | null { + return getStaticStringValue(declarations.get(name)?.init); +} + +function isAllowedIdentifier( + declarations: Map, + name: string +): boolean { + return name === "z" || name === "undefined" || declarations.has(name); +} + +function assertSafeMemberName(property: t.Identifier): void { + if (DANGEROUS_MEMBER_NAMES.has(property.name)) { + throw new Error(`Unsupported member access: ${property.name}`); + } +} + +function collectDependenciesForDeclaration( + declarations: Map, + currentDeclaration: string, + expression: t.Expression +): Set { + const dependencies = new Set(); + + const addDependency = (name: string) => { + if (name !== "z" && name !== "undefined" && name !== currentDeclaration) { + dependencies.add(name); + } + }; + + const validateMemberExpression = (node: t.MemberExpression) => { + if (node.computed || !t.isIdentifier(node.property)) { + throw new Error("Computed member access is not supported in template schemas"); + } + + assertSafeMemberName(node.property); + + const rootIdentifier = getRootIdentifier(node); + if (!rootIdentifier || !isAllowedIdentifier(declarations, rootIdentifier)) { + throw new Error(`Unsupported member access root: ${rootIdentifier ?? node.type}`); + } + + validateExpression(node.object as t.Expression); + }; + + const validateCallExpression = (node: t.CallExpression) => { + const callee = unwrapExpression(node.callee as t.Expression); + + if (t.isIdentifier(callee)) { + throw new Error(`Unsupported direct function call: ${callee.name}`); + } + + if (t.isMemberExpression(callee)) { + validateMemberExpression(callee); + } else if (t.isCallExpression(callee)) { + validateCallExpression(callee); + } else { + throw new Error(`Unsupported callee type: ${callee.type}`); + } + + for (const argument of node.arguments) { + if (t.isSpreadElement(argument)) { + validateExpression(argument.argument); + continue; + } + + if (!t.isExpression(argument)) { + throw new Error("Unsupported call argument"); + } + + validateExpression(argument); + } + }; + + const validateObjectProperty = (node: t.ObjectProperty) => { + if (node.computed) { + if (!t.isExpression(node.key)) { + throw new Error("Unsupported computed object key"); + } + validateExpression(node.key); + } else if ( + !t.isIdentifier(node.key) && + !t.isStringLiteral(node.key) && + !t.isNumericLiteral(node.key) + ) { + throw new Error(`Unsupported object key type: ${node.key.type}`); + } + + if (!t.isExpression(node.value)) { + throw new Error("Unsupported object property value"); + } + + validateExpression(node.value); + }; + + const validateExpression = (node: t.Expression) => { + const expressionNode = unwrapExpression(node); + + if ( + t.isStringLiteral(expressionNode) || + t.isNumericLiteral(expressionNode) || + t.isBooleanLiteral(expressionNode) || + t.isNullLiteral(expressionNode) || + t.isBigIntLiteral(expressionNode) || + t.isRegExpLiteral(expressionNode) + ) { + return; + } + + if (t.isIdentifier(expressionNode)) { + if (!isAllowedIdentifier(declarations, expressionNode.name)) { + throw new Error(`Unsupported identifier: ${expressionNode.name}`); + } + + addDependency(expressionNode.name); + return; + } + + if (t.isTemplateLiteral(expressionNode)) { + if (expressionNode.expressions.length > 0) { + throw new Error("Dynamic template literals are not supported in template schemas"); + } + return; + } + + if (t.isArrayExpression(expressionNode)) { + for (const element of expressionNode.elements) { + if (!element) { + continue; + } + + if (t.isSpreadElement(element)) { + validateExpression(element.argument); + continue; + } + + validateExpression(element); + } + return; + } + + if (t.isObjectExpression(expressionNode)) { + for (const property of expressionNode.properties) { + if (t.isSpreadElement(property)) { + validateExpression(property.argument); + continue; + } + + if (!t.isObjectProperty(property)) { + throw new Error(`Unsupported object property type: ${property.type}`); + } + + validateObjectProperty(property); + } + return; + } + + if (t.isMemberExpression(expressionNode)) { + validateMemberExpression(expressionNode); + return; + } + + if (t.isCallExpression(expressionNode)) { + validateCallExpression(expressionNode); + return; + } + + if (t.isUnaryExpression(expressionNode)) { + if (!["!", "+", "-", "void"].includes(expressionNode.operator)) { + throw new Error(`Unsupported unary operator: ${expressionNode.operator}`); + } + + validateExpression(expressionNode.argument); + return; + } + + throw new Error(`Unsupported expression type: ${expressionNode.type}`); + }; + + validateExpression(expression); + return dependencies; +} + +function buildSchemaRuntimeSource( + declarations: Map +): string { + const requiredDeclarations = new Set(); + const visiting = new Set(); + + const visitDeclaration = (name: string) => { + if (requiredDeclarations.has(name)) { + return; + } + + if (visiting.has(name)) { + throw new Error(`Circular schema declaration detected: ${name}`); + } + + const declaration = declarations.get(name); + if (!declaration) { + throw new Error(`Missing declaration: ${name}`); + } + + visiting.add(name); + const dependencies = collectDependenciesForDeclaration( + declarations, + name, + declaration.init + ); + + for (const dependency of dependencies) { + visitDeclaration(dependency); + } + + visiting.delete(name); + requiredDeclarations.add(name); + }; + + visitDeclaration("Schema"); + + return Array.from(declarations.values()) + .filter((declaration) => requiredDeclarations.has(declaration.name)) + .sort((left, right) => left.order - right.order) + .map( + (declaration) => + `const ${declaration.name} = ${declaration.initSource};` + ) + .join("\n"); +} + +function isZodSchema(value: unknown): value is z.ZodTypeAny { + return ( + typeof value === "object" && + value !== null && + typeof (value as z.ZodTypeAny).safeParse === "function" + ); +} + +export function compileTemplateSchema( + layoutCode: string +): CompiledTemplateSchema | null { + try { + const normalizedLayoutCode = + normalizeHardcodedBackendUrlsInCode(layoutCode); + const declarations = extractTopLevelDeclarations(normalizedLayoutCode); + + if (!declarations.has("Schema")) { + return null; + } + + const schemaRuntimeSource = buildSchemaRuntimeSource(declarations); + const factory = new Function( + "_z", + `"use strict"; const z = _z; ${schemaRuntimeSource}\nreturn Schema;` + ); + const schema = factory(z); + + if (!isZodSchema(schema)) { + return null; + } + + return { + layoutDescription: + readStringDeclaration(declarations, "layoutDescription") ?? "", + layoutId: readStringDeclaration(declarations, "layoutId") ?? "custom-layout", + layoutName: + readStringDeclaration(declarations, "layoutName") ?? "Custom Layout", + schemaJSON: z.toJSONSchema(schema), + }; + } catch (error) { + console.error("Failed to compile template schema", error); + return null; + } +} diff --git a/servers/nextjs/lib/run-bundled-pdf-export.ts b/servers/nextjs/lib/run-bundled-presentation-export.ts similarity index 94% rename from servers/nextjs/lib/run-bundled-pdf-export.ts rename to servers/nextjs/lib/run-bundled-presentation-export.ts index 015f4c10..8b240515 100644 --- a/servers/nextjs/lib/run-bundled-pdf-export.ts +++ b/servers/nextjs/lib/run-bundled-presentation-export.ts @@ -57,7 +57,9 @@ export async function bundledExportPackageAvailable(): Promise { } } -export type BundledPdfExportResult = { path: string }; +export type BundledPresentationExportFormat = "pdf" | "pptx"; + +export type BundledPresentationExportResult = { path: string }; function normalizeExportOutputPath(params: { pathValue?: string; @@ -110,11 +112,12 @@ function normalizeExportOutputPath(params: { * Runs the bundled export entrypoint (`presentation-export/index.js`) with * `BUILT_PYTHON_MODULE_PATH` pointing at the PyInstaller converter binary. */ -export async function runBundledPdfExport(params: { +export async function runBundledPresentationExport(params: { presentationId: string; title: string | undefined; -}): Promise { - const { presentationId, title } = params; + format: BundledPresentationExportFormat; +}): Promise { + const { presentationId, title, format } = params; const exportRoot = getExportPackageRoot(); const entrypoint = await resolveExportEntrypoint(exportRoot); const converter = bundledConverterPath(exportRoot); @@ -140,7 +143,7 @@ export async function runBundledPdfExport(params: { const exportTask = { type: "export", url: pptUrl, - format: "pdf", + format, title: sanitizeFilename(title ?? "presentation"), fastapiUrl: fastapiUrl || undefined, }; diff --git a/servers/nextjs/package-lock.json b/servers/nextjs/package-lock.json index 8ad2e0ea..875c5b9f 100644 --- a/servers/nextjs/package-lock.json +++ b/servers/nextjs/package-lock.json @@ -8,8 +8,10 @@ "name": "presenton", "version": "0.1.0", "dependencies": { + "@babel/parser": "^7.28.4", "@babel/standalone": "^7.28.2", "@babel/traverse": "^7.29.0", + "@babel/types": "^7.28.4", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -51,7 +53,6 @@ "next": "^14.2.14", "next-themes": "^0.4.6", "prismjs": "^1.30.0", - "puppeteer": "^24.13.0", "react": "^18.3.1", "react-colorful": "^5.6.1", "react-dom": "^18.3.1", @@ -71,7 +72,6 @@ "@types/canvas-confetti": "^1.9.0", "@types/node": "^20", "@types/prismjs": "^1.26.5", - "@types/puppeteer": "^5.4.7", "@types/react": "^18", "@types/react-dom": "^18", "@types/uuid": "^10.0.0", @@ -1707,27 +1707,6 @@ "url": "https://opencollective.com/popperjs" } }, - "node_modules/@puppeteer/browsers": { - "version": "2.10.6", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.6.tgz", - "integrity": "sha512-pHUn6ZRt39bP3698HFQlu2ZHCkS/lPcpv7fVQcGBSzNNygw171UXAKrCUhy+TEMw4lEttOKDgNpb04hwUAJeiQ==", - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.4.1", - "extract-zip": "^2.0.1", - "progress": "^2.0.3", - "proxy-agent": "^6.5.0", - "semver": "^7.7.2", - "tar-fs": "^3.1.0", - "yargs": "^17.7.2" - }, - "bin": { - "browsers": "lib/cjs/main-cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -3337,12 +3316,6 @@ "url": "https://github.com/sponsors/ueberdosis" } }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", - "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", - "license": "MIT" - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3700,7 +3673,7 @@ "version": "20.19.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.10.tgz", "integrity": "sha512-iAFpG6DokED3roLSP0K+ybeDdIX6Bc0Vd3mLW5uDqThPWtNos3E+EqOM11mPQHKzfWHqEBuLjIlsBQQ8CsISmQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3720,16 +3693,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/@types/puppeteer": { - "version": "5.4.7", - "resolved": "https://registry.npmjs.org/@types/puppeteer/-/puppeteer-5.4.7.tgz", - "integrity": "sha512-JdGWZZYL0vKapXF4oQTC5hLVNfOgdPrqeZ1BiQnGk5cB7HeE91EWUiTdVSdQPobRN8rIcdffjiOgCYJ/S8QrnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/react": { "version": "18.3.23", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", @@ -3799,6 +3762,7 @@ "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3822,15 +3786,6 @@ "node": ">=0.4.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -3992,18 +3947,6 @@ "node": ">=0.8" } }, - "node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -4055,90 +3998,12 @@ "dev": true, "license": "MIT" }, - "node_modules/b4a": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", - "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", - "license": "Apache-2.0" - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, - "node_modules/bare-events": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.6.1.tgz", - "integrity": "sha512-AuTJkq9XmE6Vk0FJVNq5QxETrSA/vKHarWVBG5l/JbdCL1prJemiyJqUS0jrlXO0MftuPq4m3YVYhoNc5+aE/g==", - "license": "Apache-2.0", - "optional": true - }, - "node_modules/bare-fs": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.6.tgz", - "integrity": "sha512-25RsLF33BqooOEFNdMcEhMpJy8EoR88zSMrnOQOaM3USnOK2VmaJ1uaQEwPA6AQjrv1lXChScosN6CzbwbO9OQ==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4" - }, - "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", - "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-stream": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", - "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "streamx": "^2.21.0" - }, - "peerDependencies": { - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, "node_modules/base64-arraybuffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", @@ -4169,15 +4034,6 @@ ], "license": "MIT" }, - "node_modules/basic-ftp": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", - "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -4264,6 +4120,7 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, "license": "MIT", "engines": { "node": "*" @@ -4321,15 +4178,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -4478,28 +4326,6 @@ "node": ">= 6" } }, - "node_modules/chromium-bidi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-7.2.0.tgz", - "integrity": "sha512-gREyhyBstermK+0RbcJLbFhcQctg92AGgDe/h/taMJEOLRdtSswBAO9KmvltFSQWgM2LrwWu5SIuEUbdm3JsyQ==", - "license": "Apache-2.0", - "dependencies": { - "mitt": "^3.0.1", - "zod": "^3.24.1" - }, - "peerDependencies": { - "devtools-protocol": "*" - } - }, - "node_modules/chromium-bidi/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/ci-info": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", @@ -4590,20 +4416,6 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -4743,32 +4555,6 @@ "layout-base": "^1.0.0" } }, - "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -5397,15 +5183,6 @@ "node": ">=0.10" } }, - "node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", @@ -5435,20 +5212,6 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, - "node_modules/degenerator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", - "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/delaunator": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", @@ -5483,12 +5246,6 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, - "node_modules/devtools-protocol": { - "version": "0.0.1475386", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1475386.tgz", - "integrity": "sha512-RQ809ykTfJ+dgj9bftdeL2vRVxASAuGU+I9LEx9Ij5TXU5HrgAQVmzi72VA+mkzscE12uzlRv5/tWWv9R9J1SA==", - "license": "BSD-3-Clause" - }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -5569,6 +5326,7 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -5600,24 +5358,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -5709,15 +5449,6 @@ "@esbuild/win32-x64": "0.25.8" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -5728,58 +5459,6 @@ "node": ">=0.8.0" } }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/eventemitter2": { "version": "6.4.7", "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz", @@ -5847,6 +5526,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "debug": "^4.1.1", @@ -5888,12 +5568,6 @@ "node": ">=6.0.0" } }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "license": "MIT" - }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -5935,6 +5609,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, "license": "MIT", "dependencies": { "pend": "~1.2.0" @@ -6062,15 +5737,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -6123,6 +5789,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, "license": "MIT", "dependencies": { "pump": "^3.0.0" @@ -6134,20 +5801,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-uri": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", - "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", - "license": "MIT", - "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/getos": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz", @@ -6334,19 +5987,6 @@ "node": ">=8.0.0" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/http-signature": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", @@ -6362,19 +6002,6 @@ "node": ">=0.10" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/human-signals": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", @@ -6428,22 +6055,6 @@ "url": "https://opencollective.com/immer" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -6473,25 +6084,6 @@ "node": ">=12" } }, - "node_modules/ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", - "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, - "engines": { - "node": ">= 12" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "license": "MIT" - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -6661,24 +6253,6 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "license": "MIT" - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -6691,12 +6265,6 @@ "node": ">=6" } }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT" - }, "node_modules/json-schema": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", @@ -7007,15 +6575,6 @@ "loose-envify": "cli.js" } }, - "node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/lucide-react": { "version": "0.447.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.447.0.tgz", @@ -7290,15 +6849,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/netmask": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", - "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/next": { "version": "14.2.31", "resolved": "https://registry.npmjs.org/next/-/next-14.2.31.tgz", @@ -7416,6 +6966,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -7466,38 +7017,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pac-proxy-agent": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", - "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", - "license": "MIT", - "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.6", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "license": "MIT", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -7510,36 +7029,6 @@ "integrity": "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==", "license": "MIT" }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/path-data-parser": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", @@ -7593,6 +7082,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, "license": "MIT" }, "node_modules/performance-now": { @@ -7818,15 +7308,6 @@ "node": ">= 0.6.0" } }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -8051,31 +7532,6 @@ "prosemirror-transform": "^1.1.0" } }, - "node_modules/proxy-agent": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", - "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.6", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.1.0", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/proxy-from-env": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", @@ -8087,6 +7543,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -8102,44 +7559,6 @@ "node": ">=6" } }, - "node_modules/puppeteer": { - "version": "24.16.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.16.0.tgz", - "integrity": "sha512-5qxFGOpdAzYexoPwKPEF4L/IYKYOFE1MxWsqcp7K33HySM8N8S/yZwSQCaV0rzmJsTLX5LxU4zt65+ceNiVDgQ==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "2.10.6", - "chromium-bidi": "7.2.0", - "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1475386", - "puppeteer-core": "24.16.0", - "typed-query-selector": "^2.12.0" - }, - "bin": { - "puppeteer": "lib/cjs/puppeteer/node/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/puppeteer-core": { - "version": "24.16.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.16.0.tgz", - "integrity": "sha512-tZ0tJiOYaDGTRzzr2giDpf8O/55JsoqkrafS1Xu4H6S8oP4eeL6RbZzY9OzjShSf5EQvx/zAc55QKpDqzXos/Q==", - "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "2.10.6", - "chromium-bidi": "7.2.0", - "debug": "^4.4.1", - "devtools-protocol": "0.0.1475386", - "typed-query-selector": "^2.12.0", - "ws": "^8.18.3" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -8444,15 +7863,6 @@ "throttleit": "^1.0.0" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -8479,15 +7889,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/restore-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", @@ -8864,44 +8265,6 @@ "node": ">=8" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.6.tgz", - "integrity": "sha512-pe4Y2yzru68lXCb38aAqRf5gvN8YdjP1lok5o0J7BOHljkyCGKVz7H3vpVIXKD27rj2giOJ7DwVyk/GWrPHDWA==", - "license": "MIT", - "dependencies": { - "ip-address": "^9.0.5", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -8912,16 +8275,6 @@ "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -8931,12 +8284,6 @@ "node": ">=0.10.0" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "license": "BSD-3-Clause" - }, "node_modules/sshpk": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", @@ -8978,19 +8325,6 @@ "node": ">=10.0.0" } }, - "node_modules/streamx": { - "version": "2.22.1", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", - "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==", - "license": "MIT", - "dependencies": { - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - }, - "optionalDependencies": { - "bare-events": "^2.2.0" - } - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -9275,40 +8609,6 @@ "node": ">=4" } }, - "node_modules/tar-fs": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.0.tgz", - "integrity": "sha512-5Mty5y/sOF1YWj1J6GiBodjlDc05CUR8PKXrsnFAiSG0xA+GHeWLovaZPYUDXkH/1iKRf2+M5+OrRgzC7O9b7w==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^4.0.1", - "bare-path": "^3.0.0" - } - }, - "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, - "node_modules/text-decoder": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", - "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -9533,17 +8833,11 @@ "node": ">=8" } }, - "node_modules/typed-query-selector": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", - "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", - "license": "MIT" - }, "node_modules/typescript": { "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -9569,7 +8863,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/universalify": { @@ -9783,6 +9077,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -9818,38 +9113,9 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, - "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/yaml": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", @@ -9862,37 +9128,11 @@ "node": ">= 14.6" } }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", diff --git a/servers/nextjs/package.json b/servers/nextjs/package.json index 6a919c7f..71424c83 100644 --- a/servers/nextjs/package.json +++ b/servers/nextjs/package.json @@ -10,8 +10,10 @@ "lint": "next lint" }, "dependencies": { + "@babel/parser": "^7.28.4", "@babel/standalone": "^7.28.2", "@babel/traverse": "^7.29.0", + "@babel/types": "^7.28.4", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -53,7 +55,6 @@ "next": "^14.2.14", "next-themes": "^0.4.6", "prismjs": "^1.30.0", - "puppeteer": "^24.13.0", "react": "^18.3.1", "react-colorful": "^5.6.1", "react-dom": "^18.3.1", @@ -73,7 +74,6 @@ "@types/canvas-confetti": "^1.9.0", "@types/node": "^20", "@types/prismjs": "^1.26.5", - "@types/puppeteer": "^5.4.7", "@types/react": "^18", "@types/react-dom": "^18", "@types/uuid": "^10.0.0", diff --git a/servers/nextjs/types/element_attibutes.ts b/servers/nextjs/types/element_attibutes.ts deleted file mode 100644 index 00d81b84..00000000 --- a/servers/nextjs/types/element_attibutes.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { ElementHandle } from "puppeteer"; - -export interface ElementAttributes { - tagName: string; - id?: string; - className?: string; - innerText?: string; - opacity?: number; - background?: { - color?: string; - opacity?: number; - }; - border?: { - color?: string; - width?: number; - opacity?: number; - }; - shadow?: { - offset?: [number, number]; - color?: string; - opacity?: number; - radius?: number; - angle?: number; - spread?: number; - inset?: boolean; - }, - font?: { - name?: string; - size?: number; - weight?: number; - color?: string; - italic?: boolean; - }; - position?: { - left?: number; - top?: number; - width?: number; - height?: number; - }; - margin?: { - top?: number; - bottom?: number; - left?: number; - right?: number; - }; - padding?: { - top?: number; - bottom?: number; - left?: number; - right?: number; - }; - zIndex?: number; - textAlign?: 'left' | 'center' | 'right' | 'justify'; - lineHeight?: number; - borderRadius?: number[]; - imageSrc?: string; - objectFit?: 'contain' | 'cover' | 'fill'; - clip?: boolean; - overlay?: string; - shape?: 'rectangle' | 'circle'; - connectorType?: string; - textWrap?: boolean; - should_screenshot?: boolean; - element?: ElementHandle; - filters?: { - invert?: number; - brightness?: number; - contrast?: number; - saturate?: number; - hueRotate?: number; - blur?: number; - grayscale?: number; - sepia?: number; - opacity?: number; - }; -} - -export interface SlideAttributesResult { - elements: ElementAttributes[]; - backgroundColor?: string; - speakerNote?: string; -} \ No newline at end of file diff --git a/servers/nextjs/types/pptx_models.ts b/servers/nextjs/types/pptx_models.ts deleted file mode 100644 index 8cf2e7c0..00000000 --- a/servers/nextjs/types/pptx_models.ts +++ /dev/null @@ -1,364 +0,0 @@ -export enum PptxBoxShapeEnum { - RECTANGLE = "rectangle", - CIRCLE = "circle" -} - -export enum PptxObjectFitEnum { - CONTAIN = "contain", - COVER = "cover", - FILL = "fill" -} - -export enum PptxAlignment { - CENTER = 2, - DISTRIBUTE = 5, - JUSTIFY = 4, - JUSTIFY_LOW = 7, - LEFT = 1, - RIGHT = 3, - THAI_DISTRIBUTE = 6, - MIXED = -2 -} - -export enum PptxShapeType { - ACTION_BUTTON_BACK_OR_PREVIOUS = 129, - ACTION_BUTTON_BEGINNING = 131, - ACTION_BUTTON_CUSTOM = 125, - ACTION_BUTTON_DOCUMENT = 134, - ACTION_BUTTON_END = 132, - ACTION_BUTTON_FORWARD_OR_NEXT = 130, - ACTION_BUTTON_HELP = 127, - ACTION_BUTTON_HOME = 126, - ACTION_BUTTON_INFORMATION = 128, - ACTION_BUTTON_MOVIE = 136, - ACTION_BUTTON_RETURN = 133, - ACTION_BUTTON_SOUND = 135, - ARC = 25, - BALLOON = 137, - BENT_ARROW = 41, - BENT_UP_ARROW = 44, - BEVEL = 15, - BLOCK_ARC = 20, - CAN = 13, - CHART_PLUS = 182, - CHART_STAR = 181, - CHART_X = 180, - CHEVRON = 52, - CHORD = 161, - CIRCULAR_ARROW = 60, - CLOUD = 179, - CLOUD_CALLOUT = 108, - CORNER = 162, - CORNER_TABS = 169, - CROSS = 11, - CUBE = 14, - CURVED_DOWN_ARROW = 48, - CURVED_DOWN_RIBBON = 100, - CURVED_LEFT_ARROW = 46, - CURVED_RIGHT_ARROW = 45, - CURVED_UP_ARROW = 47, - CURVED_UP_RIBBON = 99, - DECAGON = 144, - DIAGONAL_STRIPE = 141, - DIAMOND = 4, - DODECAGON = 146, - DONUT = 18, - DOUBLE_BRACE = 27, - DOUBLE_BRACKET = 26, - DOUBLE_WAVE = 104, - DOWN_ARROW = 36, - DOWN_ARROW_CALLOUT = 56, - DOWN_RIBBON = 98, - EXPLOSION1 = 89, - EXPLOSION2 = 90, - FLOWCHART_ALTERNATE_PROCESS = 62, - FLOWCHART_CARD = 75, - FLOWCHART_COLLATE = 79, - FLOWCHART_CONNECTOR = 73, - FLOWCHART_DATA = 64, - FLOWCHART_DECISION = 63, - FLOWCHART_DELAY = 84, - FLOWCHART_DIRECT_ACCESS_STORAGE = 87, - FLOWCHART_DISPLAY = 88, - FLOWCHART_DOCUMENT = 67, - FLOWCHART_EXTRACT = 81, - FLOWCHART_INTERNAL_STORAGE = 66, - FLOWCHART_MAGNETIC_DISK = 86, - FLOWCHART_MANUAL_INPUT = 71, - FLOWCHART_MANUAL_OPERATION = 72, - FLOWCHART_MERGE = 82, - FLOWCHART_MULTIDOCUMENT = 68, - FLOWCHART_OFFLINE_STORAGE = 139, - FLOWCHART_OFFPAGE_CONNECTOR = 74, - FLOWCHART_OR = 78, - FLOWCHART_PREDEFINED_PROCESS = 65, - FLOWCHART_PREPARATION = 70, - FLOWCHART_PROCESS = 61, - FLOWCHART_PUNCHED_TAPE = 76, - FLOWCHART_SEQUENTIAL_ACCESS_STORAGE = 85, - FLOWCHART_SORT = 80, - FLOWCHART_STORED_DATA = 83, - FLOWCHART_SUMMING_JUNCTION = 77, - FLOWCHART_TERMINATOR = 69, - FOLDED_CORNER = 16, - FRAME = 158, - FUNNEL = 174, - GEAR_6 = 172, - GEAR_9 = 173, - HALF_FRAME = 159, - HEART = 21, - HEPTAGON = 145, - HEXAGON = 10, - HORIZONTAL_SCROLL = 102, - ISOSCELES_TRIANGLE = 7, - LEFT_ARROW = 34, - LEFT_ARROW_CALLOUT = 54, - LEFT_BRACE = 31, - LEFT_BRACKET = 29, - LEFT_CIRCULAR_ARROW = 176, - LEFT_RIGHT_ARROW = 37, - LEFT_RIGHT_ARROW_CALLOUT = 57, - LEFT_RIGHT_CIRCULAR_ARROW = 177, - LEFT_RIGHT_RIBBON = 140, - LEFT_RIGHT_UP_ARROW = 40, - LEFT_UP_ARROW = 43, - LIGHTNING_BOLT = 22, - LINE_CALLOUT_1 = 109, - LINE_CALLOUT_1_ACCENT_BAR = 113, - LINE_CALLOUT_1_BORDER_AND_ACCENT_BAR = 121, - LINE_CALLOUT_1_NO_BORDER = 117, - LINE_CALLOUT_2 = 110, - LINE_CALLOUT_2_ACCENT_BAR = 114, - LINE_CALLOUT_2_BORDER_AND_ACCENT_BAR = 122, - LINE_CALLOUT_2_NO_BORDER = 118, - LINE_CALLOUT_3 = 111, - LINE_CALLOUT_3_ACCENT_BAR = 115, - LINE_CALLOUT_3_BORDER_AND_ACCENT_BAR = 123, - LINE_CALLOUT_3_NO_BORDER = 119, - LINE_CALLOUT_4 = 112, - LINE_CALLOUT_4_ACCENT_BAR = 116, - LINE_CALLOUT_4_BORDER_AND_ACCENT_BAR = 124, - LINE_CALLOUT_4_NO_BORDER = 120, - LINE_INVERSE = 183, - MATH_DIVIDE = 166, - MATH_EQUAL = 167, - MATH_MINUS = 164, - MATH_MULTIPLY = 165, - MATH_NOT_EQUAL = 168, - MATH_PLUS = 163, - MOON = 24, - NON_ISOSCELES_TRAPEZOID = 143, - NOTCHED_RIGHT_ARROW = 50, - NO_SYMBOL = 19, - OCTAGON = 6, - OVAL = 9, - OVAL_CALLOUT = 107, - PARALLELOGRAM = 2, - PENTAGON = 51, - PIE = 142, - PIE_WEDGE = 175, - PLAQUE = 28, - PLAQUE_TABS = 171, - QUAD_ARROW = 39, - QUAD_ARROW_CALLOUT = 59, - RECTANGLE = 1, - RECTANGULAR_CALLOUT = 105, - REGULAR_PENTAGON = 12, - RIGHT_ARROW = 33, - RIGHT_ARROW_CALLOUT = 53, - RIGHT_BRACE = 32, - RIGHT_BRACKET = 30, - RIGHT_TRIANGLE = 8, - ROUNDED_RECTANGLE = 5, - ROUNDED_RECTANGULAR_CALLOUT = 106, - ROUND_1_RECTANGLE = 151, - ROUND_2_DIAG_RECTANGLE = 153, - ROUND_2_SAME_RECTANGLE = 152, - SMILEY_FACE = 17, - SNIP_1_RECTANGLE = 155, - SNIP_2_DIAG_RECTANGLE = 157, - SNIP_2_SAME_RECTANGLE = 156, - SNIP_ROUND_RECTANGLE = 154, - SQUARE_TABS = 170, - STAR_10_POINT = 149, - STAR_12_POINT = 150, - STAR_16_POINT = 94, - STAR_24_POINT = 95, - STAR_32_POINT = 96, - STAR_4_POINT = 91, - STAR_5_POINT = 92, - STAR_6_POINT = 147, - STAR_7_POINT = 148, - STAR_8_POINT = 93, - STRIPED_RIGHT_ARROW = 49, - SUN = 23, - SWOOSH_ARROW = 178, - TEAR = 160, - TRAPEZOID = 3, - UP_ARROW = 35, - UP_ARROW_CALLOUT = 55, - UP_DOWN_ARROW = 38, - UP_DOWN_ARROW_CALLOUT = 58, - UP_RIBBON = 97, - U_TURN_ARROW = 42, - VERTICAL_SCROLL = 101, - WAVE = 103 -} - -export enum PptxConnectorType { - CURVE = 3, - ELBOW = 2, - STRAIGHT = 1, - MIXED = -2 -} - -export interface PptxSpacingModel { - top: number; - bottom: number; - left: number; - right: number; -} - -export interface PptxPositionModel { - left: number; - top: number; - width: number; - height: number; -} - -export interface PptxFontModel { - name: string; - size: number; - font_weight: number; - italic: boolean; - color: string; -} - -export interface PptxFillModel { - color: string; - opacity: number; -} - -export interface PptxStrokeModel { - color: string; - thickness: number; - opacity: number; -} - -export interface PptxShadowModel { - radius: number; - offset: number; - color: string; - opacity: number; - angle: number; -} - -export interface PptxTextRunModel { - text: string; - font?: PptxFontModel; -} - -export interface PptxParagraphModel { - spacing?: PptxSpacingModel; - alignment?: PptxAlignment; - font?: PptxFontModel; - line_height?: number; - text?: string; - text_runs?: PptxTextRunModel[]; -} - -export interface PptxObjectFitModel { - fit?: PptxObjectFitEnum; - focus?: [number | null, number | null]; -} - -export interface PptxPictureModel { - is_network: boolean; - path: string; -} - -export interface PptxShapeModel { -} - -export interface PptxTextBoxModel extends PptxShapeModel { - shape_type: string; - margin?: PptxSpacingModel; - fill?: PptxFillModel; - position: PptxPositionModel; - text_wrap: boolean; - paragraphs: PptxParagraphModel[]; -} - -export interface PptxAutoShapeBoxModel extends PptxShapeModel { - shape_type: string; - type?: PptxShapeType; - margin?: PptxSpacingModel; - fill?: PptxFillModel; - stroke?: PptxStrokeModel; - shadow?: PptxShadowModel; - position: PptxPositionModel; - text_wrap: boolean; - border_radius?: number; - paragraphs?: PptxParagraphModel[]; -} - -export interface PptxPictureBoxModel extends PptxShapeModel { - shape_type: string; - position: PptxPositionModel; - margin?: PptxSpacingModel; - clip: boolean; - opacity?: number; - invert?: boolean; - border_radius?: number[]; - shape?: PptxBoxShapeEnum; - object_fit?: PptxObjectFitModel; - picture: PptxPictureModel; -} - -export interface PptxConnectorModel extends PptxShapeModel { - shape_type: string; - type?: PptxConnectorType; - position: PptxPositionModel; - thickness: number; - color: string; - opacity: number; -} - -export interface PptxSlideModel { - background?: PptxFillModel; - shapes: (PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel)[]; - note?: string; -} - -export interface PptxPresentationModel { - name?: string; - shapes?: PptxShapeModel[]; - slides: PptxSlideModel[]; -} - -export const createPptxSpacingAll = (num: number): PptxSpacingModel => ({ - top: num, - left: num, - bottom: num, - right: num -}); - -export const createPptxPositionForTextbox = (left: number, top: number, width: number): PptxPositionModel => ({ - left, - top, - width, - height: 100 -}); - -export const positionToPtList = (position: PptxPositionModel): number[] => { - return [position.left, position.top, position.width, position.height]; -}; - -export const positionToPtXyxy = (position: PptxPositionModel): number[] => { - const left = position.left; - const top = position.top; - const width = position.width; - const height = position.height; - - return [left, top, left + width, top + height]; -}; diff --git a/servers/nextjs/utils/mixpanel.ts b/servers/nextjs/utils/mixpanel.ts index 17e4e12d..f8808c60 100644 --- a/servers/nextjs/utils/mixpanel.ts +++ b/servers/nextjs/utils/mixpanel.ts @@ -47,7 +47,6 @@ export enum MixpanelEvent { Header_Export_PPTX_Button_Clicked = 'Header Export PPTX Button Clicked', Header_UpdatePresentationContent_API_Call = 'Header Update Presentation Content API Call', Header_ExportAsPDF_API_Call = 'Header Export As PDF API Call', - Header_GetPptxModel_API_Call = 'Header Get PPTX Model API Call', Header_ExportAsPPTX_API_Call = 'Header Export As PPTX API Call', Slide_Add_New_Slide_Button_Clicked = 'Slide Add New Slide Button Clicked', Slide_Delete_Slide_Button_Clicked = 'Slide Delete Slide Button Clicked', diff --git a/servers/nextjs/utils/pptx_models_utils.ts b/servers/nextjs/utils/pptx_models_utils.ts deleted file mode 100644 index 7cae718c..00000000 --- a/servers/nextjs/utils/pptx_models_utils.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { ElementAttributes, SlideAttributesResult } from "@/types/element_attibutes"; -import { - PptxSlideModel, - PptxTextBoxModel, - PptxAutoShapeBoxModel, - PptxPictureBoxModel, - PptxConnectorModel, - PptxPositionModel, - PptxFillModel, - PptxStrokeModel, - PptxShadowModel, - PptxFontModel, - PptxParagraphModel, - PptxPictureModel, - PptxObjectFitModel, - PptxBoxShapeEnum, - PptxObjectFitEnum, - PptxAlignment, - PptxShapeType, - PptxConnectorType -} from "@/types/pptx_models"; - -function convertTextAlignToPptxAlignment(textAlign?: string): PptxAlignment | undefined { - if (!textAlign) return undefined; - - switch (textAlign.toLowerCase()) { - case 'left': - return PptxAlignment.LEFT; - case 'center': - return PptxAlignment.CENTER; - case 'right': - return PptxAlignment.RIGHT; - case 'justify': - return PptxAlignment.JUSTIFY; - default: - return PptxAlignment.LEFT; - } -} - -function convertLineHeightToRelative(lineHeight?: number, fontSize?: number): number | undefined { - if (!lineHeight) return undefined; - - let calculatedLineHeight = 1.2; - if (lineHeight < 10) { - calculatedLineHeight = lineHeight; - } - - if (fontSize && fontSize > 0) { - calculatedLineHeight = Math.round((lineHeight / fontSize) * 100) / 100; - } - - return calculatedLineHeight - 0.3 -} - -export function convertElementAttributesToPptxSlides( - slidesAttributes: SlideAttributesResult[] -): PptxSlideModel[] { - return slidesAttributes.map((slideAttributes) => { - const shapes = slideAttributes.elements.map(element => { - return convertElementToPptxShape(element); - }).filter(Boolean); - - const slide: PptxSlideModel = { - shapes: shapes as (PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel)[], - note: slideAttributes.speakerNote - }; - - if (slideAttributes.backgroundColor) { - slide.background = { - color: slideAttributes.backgroundColor, - opacity: 1.0 - }; - } - - return slide; - }); -} - -function convertElementToPptxShape( - element: ElementAttributes -): PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel | null { - - if (!element.position) { - return null; - } - - if (element.tagName === 'img' || (element.className && typeof element.className === 'string' && element.className.includes('image')) || element.imageSrc) { - return convertToPictureBox(element); - } - - if (element.innerText && element.innerText.trim().length > 0) { - // Use AutoShape model if there's background color and border radius - if (element.background?.color && element.borderRadius && element.borderRadius.some(radius => radius > 0)) { - return convertToAutoShapeBox(element); - } - return convertToTextBox(element); - } - - if (element.tagName === 'hr') { - return convertToConnector(element); - } - - return convertToAutoShapeBox(element); -} - -function convertToTextBox(element: ElementAttributes): PptxTextBoxModel { - const position: PptxPositionModel = { - left: Math.round(element.position?.left ?? 0), - top: Math.round(element.position?.top ?? 0), - width: Math.round(element.position?.width ?? 0), - height: Math.round(element.position?.height ?? 0) - }; - - const 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", - size: Math.round(element.font.size ?? 16), - font_weight: element.font.weight ?? 400, - italic: element.font.italic ?? false, - color: element.font.color ?? "000000" - } : undefined; - - const paragraph: PptxParagraphModel = { - spacing: undefined, - alignment: convertTextAlignToPptxAlignment(element.textAlign), - font, - line_height: convertLineHeightToRelative(element.lineHeight, element.font?.size), - text: element.innerText - }; - - return { - shape_type: "textbox", - margin: undefined, - fill, - position, - text_wrap: element.textWrap ?? true, - paragraphs: [paragraph] - }; -} - -function convertToAutoShapeBox(element: ElementAttributes): PptxAutoShapeBoxModel { - const position: PptxPositionModel = { - left: Math.round(element.position?.left ?? 0), - top: Math.round(element.position?.top ?? 0), - width: Math.round(element.position?.width ?? 0), - height: Math.round(element.position?.height ?? 0) - }; - const 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, - opacity: element.border.opacity ?? 1.0 - } : undefined; - - const shadow: PptxShadowModel | undefined = element.shadow?.color ? { - 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, - angle: Math.round(element.shadow.angle ?? 0) - } : undefined; - - 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), - font_weight: element.font.weight ?? 400, - italic: element.font.italic ?? false, - color: element.font.color ?? "000000" - } : undefined, - line_height: convertLineHeightToRelative(element.lineHeight, element.font?.size), - text: element.innerText - }] : undefined; - - const shapeType = element.borderRadius ? PptxShapeType.ROUNDED_RECTANGLE : PptxShapeType.RECTANGLE; - - let borderRadius = undefined; - for (const eachCornerRadius of element.borderRadius ?? []) { - if (eachCornerRadius > 0) { - borderRadius = Math.max(borderRadius ?? 0, eachCornerRadius); - } - } - - return { - shape_type: "autoshape", - type: shapeType, - margin: undefined, - fill, - stroke, - shadow, - position, - text_wrap: element.textWrap ?? true, - border_radius: borderRadius || undefined, - paragraphs - }; -} - -function convertToPictureBox(element: ElementAttributes): PptxPictureBoxModel { - const position: PptxPositionModel = { - left: Math.round(element.position?.left ?? 0), - top: Math.round(element.position?.top ?? 0), - width: Math.round(element.position?.width ?? 0), - height: Math.round(element.position?.height ?? 0) - }; - - const objectFit: PptxObjectFitModel = { - fit: element.objectFit ? (element.objectFit as PptxObjectFitEnum) : PptxObjectFitEnum.CONTAIN - }; - - const picture: PptxPictureModel = { - is_network: element.imageSrc ? element.imageSrc.startsWith('http') : false, - path: element.imageSrc || '' - }; - - return { - shape_type: "picture", - position, - margin: undefined, - clip: element.clip ?? true, - invert: element.filters?.invert === 1, - opacity: element.opacity, - 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 - }; -} - -function convertToConnector(element: ElementAttributes): PptxConnectorModel { - const position: PptxPositionModel = { - left: Math.round(element.position?.left ?? 0), - top: Math.round(element.position?.top ?? 0), - width: Math.round(element.position?.width ?? 0), - height: Math.round(element.position?.height ?? 0) - }; - - return { - shape_type: "connector", - type: PptxConnectorType.STRAIGHT, - position, - thickness: element.border?.width ?? 0.5, - color: element.border?.color || element.background?.color || '000000', - opacity: element.border?.opacity ?? 1.0 - }; -}