diff --git a/apps/editor/src/services/importing/pptx/pptx-parser-model.ts b/apps/editor/src/services/importing/pptx/pptx-parser-model.ts index 8d75ddad..86382c3f 100644 --- a/apps/editor/src/services/importing/pptx/pptx-parser-model.ts +++ b/apps/editor/src/services/importing/pptx/pptx-parser-model.ts @@ -110,6 +110,7 @@ export type PptxSlideObject = }; export interface PptxSlide { + backgroundAssetPath?: string; backgroundColor: string; id: string; layoutId?: string; @@ -125,6 +126,7 @@ export interface PptxSlide { } export interface PptxLayout { + backgroundAssetPath?: string; backgroundColor: string; id: string; name: string; diff --git a/apps/editor/src/services/importing/pptx/pptx-visual-style.ts b/apps/editor/src/services/importing/pptx/pptx-visual-style.ts index dbfe1e58..d8d4b2a1 100644 --- a/apps/editor/src/services/importing/pptx/pptx-visual-style.ts +++ b/apps/editor/src/services/importing/pptx/pptx-visual-style.ts @@ -67,6 +67,7 @@ function getHexColor(element: ParentNode | undefined, fallback: string, theme?: } function toUnitInterval(value: string | null | undefined) { + if (value === null || value === undefined || value.trim() === '') return undefined; const numericValue = Number(value); return Number.isFinite(numericValue) ? Math.max(0, Math.min(1, numericValue / 100000)) diff --git a/apps/editor/src/services/importing/pptx/pptxParser.ts b/apps/editor/src/services/importing/pptx/pptxParser.ts index 372ab85b..7559b9f9 100644 --- a/apps/editor/src/services/importing/pptx/pptxParser.ts +++ b/apps/editor/src/services/importing/pptx/pptxParser.ts @@ -243,6 +243,19 @@ function getBackgroundColor(document: Document, theme: ParseScope['theme'], fall return pptxVisualStyle.getHexColor(background, fallback, theme); } +function getBackgroundAssetPath( + document: Document, + relationships: Map, +) { + const background = pptxXml.firstDescendant(document, 'bgPr'); + const blip = background ? pptxXml.firstDescendant(background, 'blip') : undefined; + const relationshipId = pptxXml.getRelationshipAttr(blip, 'embed'); + if (!relationshipId) return undefined; + const relationship = relationships.get(relationshipId); + if (!relationship || relationship.targetMode !== 'Internal') return undefined; + return relationship.target; +} + function findRelationshipByType(relationships: Map, typeSuffix: string) { return Array.from(relationships.values()).find((relationship) => relationship.type.endsWith(typeSuffix), @@ -844,7 +857,9 @@ async function parseLayout( ...object, zIndex: index, })); + const backgroundAssetPath = getBackgroundAssetPath(document, relationships); return { + ...(backgroundAssetPath ? { backgroundAssetPath } : {}), backgroundColor: getBackgroundColor(document, theme, '#FFFFFF'), id: layoutId, name: getLayoutNameFromDocument(document, layoutPath), @@ -902,7 +917,10 @@ async function parseSlide( if (object.kind === 'video' && startTrigger) object.startTrigger = startTrigger; } const resolvedLayoutId = parsedLayout?.id ?? layoutId; + const backgroundAssetPath = + getBackgroundAssetPath(document, rels) ?? parsedLayout?.backgroundAssetPath; return { + ...(backgroundAssetPath ? { backgroundAssetPath } : {}), backgroundColor: getBackgroundColor(document, theme, parsedLayout?.backgroundColor ?? '#000000'), id: slideId, ...(resolvedLayoutId ? { layoutId: resolvedLayoutId } : {}), diff --git a/apps/editor/src/services/importing/pptx/pptxProjectMapper.ts b/apps/editor/src/services/importing/pptx/pptxProjectMapper.ts index c38253a2..48816e4c 100644 --- a/apps/editor/src/services/importing/pptx/pptxProjectMapper.ts +++ b/apps/editor/src/services/importing/pptx/pptxProjectMapper.ts @@ -322,7 +322,7 @@ function mapObject( pageId: string, pageWidth: number, pageHeight: number, - backgroundColor: string, + backgroundColor: string | undefined, layoutId?: string, ): DesignElement | undefined { if (object.kind === 'text') { @@ -348,7 +348,7 @@ function mapObject( ...(object.placeholderRole ? { placeholderRole: object.placeholderRole } : {}), importSource: getImportSource(object, pageId, layoutId), ...style, - fill: getReadableTextFill(style.fill, backgroundColor), + fill: backgroundColor ? getReadableTextFill(style.fill, backgroundColor) : style.fill, fontSize, }; } @@ -423,6 +423,32 @@ const defaultPlaceholderVisibility: Record = { title: true, }; +function mapBackgroundImage( + backgroundAssetPath: string | undefined, + pptxPackage: PptxPackage, + assets: Record, + pageId: string, + pageWidth: number, + pageHeight: number, +): DesignElement | undefined { + if (!backgroundAssetPath) return undefined; + const asset = getOrCreateAsset(backgroundAssetPath, pptxPackage, assets); + if (!asset || asset.type !== 'image') return undefined; + return { + id: `${pageId}-background-image`, + type: 'image', + assetId: asset.id, + x: 0, + y: 0, + width: pageWidth, + height: pageHeight, + rotation: 0, + locked: false, + visible: true, + opacity: 1, + }; +} + function createLayout( layout: PptxLayout, pptxPackage: PptxPackage, @@ -442,7 +468,7 @@ function createLayout( layout.id, pageWidth, pageHeight, - layout.backgroundColor, + layout.backgroundAssetPath ? undefined : layout.backgroundColor, layout.id, ); if (!element) continue; @@ -472,6 +498,7 @@ function createSlideFallbackLayout( return createLayout( { backgroundColor: slide.backgroundColor, + ...(slide.backgroundAssetPath ? { backgroundAssetPath: slide.backgroundAssetPath } : {}), id: slide.layoutId, name: slide.layoutName ?? slide.layoutId, objects: slide.layoutObjects, @@ -502,6 +529,18 @@ function map(deck: PptxDeck, pptxPackage: PptxPackage): ProjectDocument { ? undefined : createSlideFallbackLayout(slide, pptxPackage, assets, warnings, deck.width, deck.height); if (layout) slideLayouts[layout.id] = layout; + const backgroundImage = mapBackgroundImage( + slide.backgroundAssetPath, + pptxPackage, + assets, + slide.id, + deck.width, + deck.height, + ); + if (backgroundImage) { + elements[backgroundImage.id] = backgroundImage; + elementIds.push(backgroundImage.id); + } for (const object of slide.objects.sort((left, right) => left.zIndex - right.zIndex)) { const element = mapObject( object, @@ -511,7 +550,7 @@ function map(deck: PptxDeck, pptxPackage: PptxPackage): ProjectDocument { slide.id, deck.width, deck.height, - slide.backgroundColor, + slide.backgroundAssetPath ? undefined : slide.backgroundColor, ); if (!element) continue; elements[element.id] = element; diff --git a/apps/editor/src/services/importing/pptx/pptxTextParser.ts b/apps/editor/src/services/importing/pptx/pptxTextParser.ts index 5d48b4ae..dd617c7c 100644 --- a/apps/editor/src/services/importing/pptx/pptxTextParser.ts +++ b/apps/editor/src/services/importing/pptx/pptxTextParser.ts @@ -95,8 +95,14 @@ function getTextBody(shape: Element) { return pptxXml.firstDescendant(shape, 'txBody'); } -function getFirstRunProperties(paragraph: Element | undefined) { - const run = paragraph ? pptxXml.firstDescendant(paragraph, 'r') : undefined; +function getDominantRunProperties(paragraph: Element | undefined) { + const runs = paragraph ? pptxXml.descendants(paragraph, 'r') : []; + const run = runs.reduce((dominant, candidate) => { + if (!dominant) return candidate; + const dominantLength = pptxXml.textContent(dominant, 't').trim().length; + const candidateLength = pptxXml.textContent(candidate, 't').trim().length; + return candidateLength > dominantLength ? candidate : dominant; + }, undefined); return run ? pptxXml.firstDescendant(run, 'rPr') : undefined; } @@ -156,7 +162,7 @@ function hasLineSpacing(...paragraphProperties: Array) { function getLocalStyleSources(shape: Element) { const paragraph = getFirstParagraph(shape); const paragraphProperties = paragraph ? pptxXml.firstDescendant(paragraph, 'pPr') : undefined; - const runProperties = getFirstRunProperties(paragraph); + const runProperties = getDominantRunProperties(paragraph); const paragraphDefaultRunProperties = getParagraphDefaultRunProperties(paragraphProperties); const textBodyListDefaultRunProperties = getTextBodyListDefaultRunProperties(shape); const listParagraphProperties = getListParagraphProperties(shape); diff --git a/apps/editor/src/services/storage/assetFileUtils.ts b/apps/editor/src/services/storage/assetFileUtils.ts index 87f2d081..7eb544ce 100644 --- a/apps/editor/src/services/storage/assetFileUtils.ts +++ b/apps/editor/src/services/storage/assetFileUtils.ts @@ -37,6 +37,23 @@ function dataUrlToBlob(dataUrl: string) { return new Blob([bytes], { type: mimeType }); } +function blobToDataUrl(blob: Blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener('load', () => { + if (typeof reader.result === 'string') { + resolve(reader.result); + return; + } + reject(new Error('Asset could not be read as a data URL.')); + }); + reader.addEventListener('error', () => { + reject(reader.error ?? new Error('Asset could not be read.')); + }); + reader.readAsDataURL(blob); + }); +} + function isReadableObjectUrl(value: string | undefined): value is string { return isDataUrl(value) || isBlobUrl(value); } @@ -79,6 +96,7 @@ export const assetFileUtils = { isBlobUrl, isSafeRemoteUrl, dataUrlToBlob, + blobToDataUrl, isReadableObjectUrl, objectUrlToBlob, objectUrlToBlobIfReadable, diff --git a/apps/editor/src/ui/editor/browser/editorShellBrowserUtils.ts b/apps/editor/src/ui/editor/browser/editorShellBrowserUtils.ts index 0b3fa7fe..f36888a6 100644 --- a/apps/editor/src/ui/editor/browser/editorShellBrowserUtils.ts +++ b/apps/editor/src/ui/editor/browser/editorShellBrowserUtils.ts @@ -1,8 +1,11 @@ import type { WebMcpModelContext } from '../../../services/webmcp/webMcpToolAdapter'; +import { assetFileUtils } from '../../../services/storage/assetFileUtils'; +import type { SlideClipboardState } from '../state/editorViewModelElements'; const EDITOR_OBJECT_CLIPBOARD_TYPE = 'application/x-localstudio-editor-elements'; const EDITOR_OBJECT_CLIPBOARD_MARKER = '1'; const MAX_EDITOR_OBJECT_CLIPBOARD_BYTES = 1024 * 1024; +const MAX_SLIDE_CLIPBOARD_BYTES = 16 * 1024 * 1024; const SLIDE_CLIPBOARD_PREFIX = 'LocalStudio.dev slide: '; function isEditableElement(target: EventTarget | null) { @@ -66,21 +69,64 @@ function readEditorObjectClipboardPayload(clipboardData: DataTransfer | null) { return payload; } -async function writeSlideClipboardPayload(payload: string) { - if (payload.length > MAX_EDITOR_OBJECT_CLIPBOARD_BYTES || !navigator.clipboard?.writeText) return false; +async function resolveSlideClipboardPayload(payload: string | Promise) { + const resolvedPayload = await payload; + if (resolvedPayload.length > MAX_SLIDE_CLIPBOARD_BYTES) { + throw new Error('Slide clipboard payload exceeds the supported size.'); + } + return `${SLIDE_CLIPBOARD_PREFIX}${resolvedPayload}`; +} + +async function writeSlideClipboardPayload(payload: string | Promise) { + if (!navigator.clipboard) return false; try { - await navigator.clipboard.writeText(`${SLIDE_CLIPBOARD_PREFIX}${payload}`); + if (navigator.clipboard.write && typeof ClipboardItem !== 'undefined') { + const clipboardText = resolveSlideClipboardPayload(payload); + await navigator.clipboard.write([ + new ClipboardItem({ + 'text/plain': clipboardText.then( + (text) => new Blob([text], { type: 'text/plain' }), + ), + }), + ]); + return true; + } + if (!navigator.clipboard.writeText) return false; + await navigator.clipboard.writeText(await resolveSlideClipboardPayload(payload)); return true; } catch { return false; } } +async function makeSlideClipboardPayloadTransferable( + payload: SlideClipboardState, + requestFetch: typeof fetch = globalThis.fetch.bind(globalThis), +) { + const assets = Object.fromEntries( + await Promise.all( + Object.entries(payload.assets).map(async ([assetId, asset]) => { + if (!assetFileUtils.isBlobUrl(asset.objectUrl)) return [assetId, asset] as const; + try { + const blob = await assetFileUtils.objectUrlToBlob(asset.objectUrl, requestFetch); + return [ + assetId, + { ...asset, objectUrl: await assetFileUtils.blobToDataUrl(blob) }, + ] as const; + } catch { + return [assetId, asset] as const; + } + }), + ), + ); + return { ...payload, assets }; +} + function readSlideClipboardPayload(clipboardData: DataTransfer | null) { const text = clipboardData?.getData?.('text/plain') ?? ''; if (!text.startsWith(SLIDE_CLIPBOARD_PREFIX)) return undefined; const payload = text.slice(SLIDE_CLIPBOARD_PREFIX.length); - return payload.length <= MAX_EDITOR_OBJECT_CLIPBOARD_BYTES ? payload : undefined; + return payload.length <= MAX_SLIDE_CLIPBOARD_BYTES ? payload : undefined; } function isWebMcpEnabled() { @@ -107,6 +153,7 @@ export const editorShellBrowserUtils = { writeEditorObjectClipboardPayload, readEditorObjectClipboardPayload, writeSlideClipboardPayload, + makeSlideClipboardPayloadTransferable, readSlideClipboardPayload, isWebMcpEnabled, isWebMcpProtocolEnabled, diff --git a/apps/editor/src/ui/editor/canvas/CanvasWorkspace.tsx b/apps/editor/src/ui/editor/canvas/CanvasWorkspace.tsx index bf91cd84..7cadd47c 100644 --- a/apps/editor/src/ui/editor/canvas/CanvasWorkspace.tsx +++ b/apps/editor/src/ui/editor/canvas/CanvasWorkspace.tsx @@ -10,7 +10,7 @@ import { type RefObject, } from 'react'; import type Konva from 'konva'; -import { Circle, Group, Layer, Rect, Stage, Text, Transformer } from 'react-konva'; +import { Circle, Group, Image as KonvaImage, Layer, Rect, Stage, Text, Transformer } from 'react-konva'; import type { AlignMode, ElementFramePatch, @@ -255,6 +255,11 @@ export function CanvasWorkspace({ | undefined >(); const page = project.pages.find((item) => item.id === activePageId) ?? project.pages[0]; + const pageBackgroundAssetUrl = + page?.background.type === 'asset' + ? project.assets[page.background.assetId]?.objectUrl + : undefined; + const pageBackgroundImage = canvasWorkspaceUtils.useCanvasImage(pageBackgroundAssetUrl); const activeLayout = page?.layoutId ? project.slideLayouts?.[page.layoutId] : undefined; const layoutVisibleElements = useMemo( () => @@ -1268,6 +1273,16 @@ export function CanvasWorkspace({ x={0} y={0} /> + {pageBackgroundImage ? ( + + ) : null} {visibleElements.map((element) => { const animationState = getElementAnimationState(element); const isLayoutElement = layoutVisibleElementIds.has(element.id); diff --git a/apps/editor/src/ui/editor/canvas/ScrollingCanvasWorkspace.tsx b/apps/editor/src/ui/editor/canvas/ScrollingCanvasWorkspace.tsx index 20b829e3..6e1f9ac8 100644 --- a/apps/editor/src/ui/editor/canvas/ScrollingCanvasWorkspace.tsx +++ b/apps/editor/src/ui/editor/canvas/ScrollingCanvasWorkspace.tsx @@ -5,6 +5,7 @@ import { type ReactNode, useEffect, useImperativeHandle, + useLayoutEffect, useRef, } from 'react'; import type { ElementStylePatch } from '../../../domain/commands/elements/basicCommands'; @@ -88,7 +89,7 @@ export const ScrollingCanvasWorkspace = forwardRef scrollerRef.current as HTMLDivElement, []); - useEffect(() => { + useLayoutEffect(() => { if (ignoreNextActiveScrollRef.current) { ignoreNextActiveScrollRef.current = false; return; diff --git a/apps/editor/src/ui/editor/panels/LayersPanel.tsx b/apps/editor/src/ui/editor/panels/LayersPanel.tsx index 0f00f639..9ebbcb20 100644 --- a/apps/editor/src/ui/editor/panels/LayersPanel.tsx +++ b/apps/editor/src/ui/editor/panels/LayersPanel.tsx @@ -36,6 +36,7 @@ interface LayersPanelProps { activePageId: string; selection: SelectionState; onSelectElement?: (elementId: string, options?: { additive?: boolean }) => void; + onSelectSlide?: () => void; onSetElementVisibility?: (elementId: string, visible: boolean) => void; onSetElementLock?: (elementId: string, locked: boolean) => void; onDeleteElement?: (elementId: string) => void; @@ -68,6 +69,9 @@ function getLayerLabel(element: DesignElement, project: ProjectDocument) { if (element.id === 'text-subtitle') return 'Subtitle'; if (element.id.startsWith('text-subtitle-copy')) return 'Subtitle copy'; if (element.id === 'image-hero') return 'Selected Image'; + if (element.type === 'image' && element.id.endsWith('-background-image')) { + return `Slide background — ${project.assets[element.assetId]?.name ?? 'Imported Image'}`; + } if (element.type === 'image' && element.assetId === 'asset-hero') return 'Selected Image copy'; if (element.type === 'image' && element.id.includes('-copy')) { return `${project.assets[element.assetId]?.name ?? 'Imported Image'} copy`; @@ -104,6 +108,7 @@ export function LayersPanel({ activePageId, selection, onSelectElement, + onSelectSlide, onSetElementVisibility, onSetElementLock, onDeleteElement, @@ -420,7 +425,18 @@ export function LayersPanel({ ); })} -
+
onSelectSlide?.()} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + onSelectSlide?.(); + }} + > Page Background diff --git a/apps/editor/src/ui/editor/panels/LeftToolPanel.tsx b/apps/editor/src/ui/editor/panels/LeftToolPanel.tsx index c9b32e24..d0d3d16e 100644 --- a/apps/editor/src/ui/editor/panels/LeftToolPanel.tsx +++ b/apps/editor/src/ui/editor/panels/LeftToolPanel.tsx @@ -117,6 +117,7 @@ interface LeftToolPanelProps { activePageId: string; selection: SelectionState; onSelectElement?: ((elementId: string, options?: { additive?: boolean }) => void) | undefined; + onSelectSlide?: (() => void) | undefined; onSetElementVisibility?: ((elementId: string, visible: boolean) => void) | undefined; onSetElementLock?: ((elementId: string, locked: boolean) => void) | undefined; onDeleteElement?: ((elementId: string) => void) | undefined; @@ -223,6 +224,7 @@ export function LeftToolPanel({ activePageId, selection, onSelectElement, + onSelectSlide, onSetElementVisibility, onSetElementLock, onDeleteElement, @@ -347,6 +349,7 @@ export function LeftToolPanel({ activePageId={activePageId} selection={selection} {...(onSelectElement ? { onSelectElement } : {})} + {...(onSelectSlide ? { onSelectSlide } : {})} {...(onSetElementVisibility ? { onSetElementVisibility } : {})} {...(onSetElementLock ? { onSetElementLock } : {})} {...(onDeleteElement ? { onDeleteElement } : {})} diff --git a/apps/editor/src/ui/editor/shell/EditorLeftPanelSurface.tsx b/apps/editor/src/ui/editor/shell/EditorLeftPanelSurface.tsx index 2fcb37dc..c0a56221 100644 --- a/apps/editor/src/ui/editor/shell/EditorLeftPanelSurface.tsx +++ b/apps/editor/src/ui/editor/shell/EditorLeftPanelSurface.tsx @@ -40,6 +40,7 @@ export function EditorLeftPanelSurface({ onDownloadFont={isHistoryReadOnly ? undefined : vm.downloadFontForSelection} onImportLocalFont={isHistoryReadOnly ? undefined : vm.importLocalFontForSelection} onSelectElement={isHistoryReadOnly ? undefined : onSelectElement} + onSelectSlide={isHistoryReadOnly ? undefined : vm.selectSlideBackground} onSetElementVisibility={isHistoryReadOnly ? undefined : vm.setElementVisibility} onSetElementLock={isHistoryReadOnly ? undefined : vm.setElementLock} onDeleteElement={isHistoryReadOnly ? undefined : vm.deleteElement} diff --git a/apps/editor/src/ui/editor/shell/EditorShell.tsx b/apps/editor/src/ui/editor/shell/EditorShell.tsx index 232cc758..4b17e438 100644 --- a/apps/editor/src/ui/editor/shell/EditorShell.tsx +++ b/apps/editor/src/ui/editor/shell/EditorShell.tsx @@ -1331,7 +1331,10 @@ function EditorDesktopShell({ services }: EditorShellProps) { async function copyPageToClipboard(pageId: string) { const payload = vm.getSlideClipboardPayload(pageId); if (!payload) return; - await editorShellBrowserUtils.writeSlideClipboardPayload(JSON.stringify(payload)); + const transferablePayload = editorShellBrowserUtils + .makeSlideClipboardPayloadTransferable(payload) + .then((nextPayload) => JSON.stringify(nextPayload)); + await editorShellBrowserUtils.writeSlideClipboardPayload(transferablePayload); } useEffect(() => { diff --git a/apps/editor/tests/unit/services/pptxImportService.test.ts b/apps/editor/tests/unit/services/pptxImportService.test.ts index be2cf677..9d41feba 100644 --- a/apps/editor/tests/unit/services/pptxImportService.test.ts +++ b/apps/editor/tests/unit/services/pptxImportService.test.ts @@ -440,6 +440,43 @@ function createStandardsFixture() { } describe('BrowserPptxImportService', () => { + it('imports image-filled slide backgrounds and uses the dominant text run style', async () => { + const imageBackgroundSlideXml = slideXml + .replace( + '', + '', + ) + .replace( + 'Editable title', + '</ Editable title', + ); + const service = new BrowserPptxImportService(); + const project = await service.importPowerPoint({ file: createPptxFixture(imageBackgroundSlideXml) }); + const page = project.pages[0]; + const title = Object.values(project.elements).find( + (element) => element.type === 'text' && element.text === ' project.elements[elementId]) + .find((element) => element?.id.endsWith('-background-image')); + expect(backgroundImage).toMatchObject({ + type: 'image', + x: 0, + y: 0, + width: 1920, + height: 1080, + locked: false, + }); + if (!backgroundImage || backgroundImage.type !== 'image') { + throw new Error('Expected an editable background image layer.'); + } + expect(page?.elementIds[0]).toBe(backgroundImage.id); + expect(project.assets[backgroundImage.assetId]?.fileName).toBe('image1.png'); + expect(title).toMatchObject({ fill: '#FFFFFF', fontFamily: 'Roboto' }); + }); + it('imports editable text, original images, and playable video assets from PPTX', async () => { const service = new BrowserPptxImportService(); const project = await service.importPowerPoint({ file: createPptxFixture() }); diff --git a/apps/editor/tests/unit/services/pptxSampleRegression.test.ts b/apps/editor/tests/unit/services/pptxSampleRegression.test.ts index 52295c6d..9d9d3810 100644 --- a/apps/editor/tests/unit/services/pptxSampleRegression.test.ts +++ b/apps/editor/tests/unit/services/pptxSampleRegression.test.ts @@ -1,12 +1,82 @@ import { readFile } from 'node:fs/promises'; import { describe, expect, it } from 'vitest'; import { BrowserPptxImportService } from '../../../src/services/importing/pptx/pptxImportService'; +import { pptxZip } from '../../../src/services/importing/pptx/pptxZip'; const samplePath = '/Users/erickwendel/Downloads/web-ai-beyond-chat-codecon-meetup-26052026.pptx'; +const browserAiSamplePath = + '/Users/erickwendel/Downloads/A revolução de IA integrada em navegadores.pptx'; const sampleRegressionEnabled = process.env.LOCALSTUDIO_PPTX_SAMPLE_REGRESSION === '1'; const sampleTest = sampleRegressionEnabled ? it : it.skip; describe('PowerPoint sample regression', () => { + sampleTest('preserves image backgrounds and dominant text styling in the browser AI deck', async () => { + const bytes = await readFile(browserAiSamplePath); + const service = new BrowserPptxImportService(); + const packageFiles = await pptxZip.readPackage( + new File([bytes], 'A revolução de IA integrada em navegadores.pptx'), + ); + const slideOneImageSizes = packageFiles + .filter((file) => + /ppt\/media\/(image1\.png|image4\.png|image9\.png|image19\.png|image37\.jpg|image43\.png)$/.test( + file.path, + ), + ) + .map((file) => file.blob.size); + const project = await service.importPowerPoint({ + file: new File([bytes], 'A revolução de IA integrada em navegadores.pptx', { + type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + }), + }); + const firstPage = project.pages[0]; + const title = firstPage?.elementIds + .map((elementId) => project.elements[elementId]) + .find( + (element) => + element?.type === 'text' && + element.text.includes('A revolução de IA integrada em navegadores'), + ); + + expect(project.pages).toHaveLength(59); + expect(firstPage?.background).toEqual({ type: 'color', color: '#FFFFFF' }); + if (!firstPage) throw new Error('Expected slide 1.'); + const backgroundImage = project.elements[firstPage.elementIds[0] ?? '']; + expect(backgroundImage).toMatchObject({ + id: `${firstPage.id}-background-image`, + type: 'image', + x: 0, + y: 0, + width: firstPage.width, + height: firstPage.height, + locked: false, + }); + if (!backgroundImage || backgroundImage.type !== 'image') { + throw new Error('Expected slide 1 to preserve its editable background image.'); + } + expect(project.assets[backgroundImage.assetId]?.fileName).toBe('image47.png'); + expect(title).toMatchObject({ fill: '#FFFFFF', fontFamily: 'Roboto' }); + const firstPageImages = firstPage.elementIds + .map((elementId) => project.elements[elementId]) + .filter((element) => element?.type === 'image'); + expect(firstPageImages).toHaveLength(7); + expect(firstPageImages.every((element) => element.opacity === 1)).toBe(true); + expect(firstPageImages.every((element) => project.assets[element.assetId]?.objectUrl)).toBe(true); + expect( + firstPageImages.map((element) => project.assets[element.assetId]?.fileName).sort(), + ).toEqual( + [ + 'image1.png', + 'image4.png', + 'image9.png', + 'image19.png', + 'image37.jpg', + 'image43.png', + 'image47.png', + ].sort(), + ); + expect(slideOneImageSizes.every((size) => size > 1_000)).toBe(true); + }); + sampleTest('imports the exported Codecon PPTX with editable objects and media', async () => { const bytes = await readFile(samplePath); const service = new BrowserPptxImportService(); diff --git a/apps/editor/tests/unit/ui/editor/EditorShell.clipboard.test.tsx b/apps/editor/tests/unit/ui/editor/EditorShell.clipboard.test.tsx index 965fab7f..e0ca0385 100644 --- a/apps/editor/tests/unit/ui/editor/EditorShell.clipboard.test.tsx +++ b/apps/editor/tests/unit/ui/editor/EditorShell.clipboard.test.tsx @@ -115,7 +115,7 @@ describe('EditorShell clipboard workflows', () => { initialProject.assets['asset-hero'] = { ...initialProject.assets['asset-hero']!, fileName: 'hero.png', - objectUrl: 'data:image/png;base64,aGVyby1ieXRlcw==', + objectUrl: 'blob:https://localstudio.dev/hero', storage: 'file', }; const backgroundAsset = { @@ -148,6 +148,9 @@ describe('EditorShell clipboard workflows', () => { services.projectRepository = repository; services.mirrorService = mirrorService; const writeText = vi.fn<(text: string) => Promise>(() => Promise.resolve()); + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + blob: () => Promise.resolve(new Blob(['hero-bytes'], { type: 'image/png' })), + } as Response); Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText }, @@ -163,8 +166,10 @@ describe('EditorShell clipboard workflows', () => { mirrorService.syncProject.mockClear(); await user.click(screen.getByRole('button', { name: 'Copy Slide 1 to clipboard' })); + await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1)); const copiedText = writeText.mock.calls[0]?.[0]; expect(copiedText).toContain('asset-background'); + expect(copiedText).toContain('data:image/png;base64,aGVyby1ieXRlcw=='); fireEvent.paste(window, { clipboardData: { @@ -180,7 +185,7 @@ describe('EditorShell clipboard workflows', () => { if (pastedPage?.background.type !== 'asset') throw new Error('Expected an asset background.'); expect(pastedPage.background.assetId).not.toBe('asset-background'); expect(savedProject?.assets[pastedPage.background.assetId]).toMatchObject({ - objectUrl: backgroundAsset.objectUrl, + objectUrl: 'data:image/png;base64,aGVyby1ieXRlcw==', }); expect(savedProject?.assets[pastedPage.background.assetId]).not.toHaveProperty('fileName'); expect(savedProject?.assets[pastedPage.background.assetId]).not.toHaveProperty('storage'); diff --git a/tests/e2e/editor/drag-reorder-animation-build-journey.ts b/tests/e2e/editor/drag-reorder-animation-build-journey.ts index f36edb37..1bf3c653 100644 --- a/tests/e2e/editor/drag-reorder-animation-build-journey.ts +++ b/tests/e2e/editor/drag-reorder-animation-build-journey.ts @@ -6,7 +6,9 @@ import { selectLayerAndExpectCanvasSelection } from './drag-reorder-layer-select export async function reorderAnimationBuilds(editor: EditorAppPage, page: Page) { await editor.openTool('Layout'); - await expect(page.locator('.layer-list article[role="button"]')).toHaveCount(2); + await expect( + page.locator('.layer-list article[role="button"]:not(.layer-row-static)'), + ).toHaveCount(2); await selectLayerAndExpectCanvasSelection(page, 'Layer text', /text-/); await editor.openTool('Animate'); await expect(page.getByRole('button', { name: 'Add animation' })).toBeEnabled(); diff --git a/tests/e2e/editor/drag-reorder-layer-journey.ts b/tests/e2e/editor/drag-reorder-layer-journey.ts index 7e1e0c6c..fda3af39 100644 --- a/tests/e2e/editor/drag-reorder-layer-journey.ts +++ b/tests/e2e/editor/drag-reorder-layer-journey.ts @@ -6,7 +6,7 @@ import { expect } from '../support/journey-test'; export async function reorderCanvasLayers(editor: EditorAppPage, page: Page) { await editor.openTool('Layout'); - const layerRows = page.locator('.layer-list article[role="button"]'); + const layerRows = page.locator('.layer-list article[role="button"]:not(.layer-row-static)'); await expect(layerRows).toHaveCount(2); await expect(layerRows.nth(0)).toHaveAttribute('aria-label', 'Background Shape'); await expect(layerRows.nth(1)).toHaveAttribute('aria-label', 'Layer text'); diff --git a/tests/e2e/editor/editor-helper-coverage-contract-browser.ts b/tests/e2e/editor/editor-helper-coverage-contract-browser.ts index a8d3387d..f4d74618 100644 --- a/tests/e2e/editor/editor-helper-coverage-contract-browser.ts +++ b/tests/e2e/editor/editor-helper-coverage-contract-browser.ts @@ -158,6 +158,9 @@ export async function evaluateEditorHelperCoverageContract() { const blipOpacity = pptxVisualStyle.getOpacity( parseXml(''), ); + const defaultBlipOpacity = pptxVisualStyle.getOpacity( + parseXml(''), + ); const solidOpacity = pptxVisualStyle.getOpacity( parseXml(''), ); @@ -213,7 +216,7 @@ export async function evaluateEditorHelperCoverageContract() { generatedElementIds: generatedProject.pages[0]?.elementIds, generatedPageName: generatedProject.pages[0]?.name, normalizedCrop, - opacities: [blipOpacity, solidOpacity], + opacities: [blipOpacity, defaultBlipOpacity, solidOpacity], polygonPoints, shapeLabels, visualColors: [themedColor, shadedColor, systemColor], diff --git a/tests/e2e/editor/import-export.spec.ts b/tests/e2e/editor/import-export.spec.ts index 3b04178b..caa00cd4 100644 --- a/tests/e2e/editor/import-export.spec.ts +++ b/tests/e2e/editor/import-export.spec.ts @@ -4,10 +4,113 @@ import { installPptxFilePicker } from '../support/pptx-file-picker'; import { createLayoutPptxFixture } from '../support/pptx-layout-fixture'; import { createTinyPngFixture } from '../support/test-assets'; import { expect, test, withIsolatedDevServer } from '../support/journey-test'; +import { readCanvasPixel } from './pptx-background-browser'; const getServer = withIsolatedDevServer(test); +const browserAiSamplePath = + '/Users/erickwendel/Downloads/A revolução de IA integrada em navegadores.pptx'; +const sampleRegressionEnabled = process.env.LOCALSTUDIO_PPTX_SAMPLE_REGRESSION === '1'; test.describe('editor import and export journey', () => { + test('renders and copies the browser AI deck image background across editor tabs', async ({ + context, + page, + }, testInfo) => { + test.skip(!sampleRegressionEnabled, 'Requires the local browser AI sample deck.'); + test.setTimeout(120_000); + const editor = new EditorAppPage(page, getServer().baseURL); + await editor.gotoNewProject(); + + await installPptxFilePicker(page, browserAiSamplePath); + await editor.openMenu('File'); + await page.getByRole('menuitem', { name: 'Import' }).click(); + await page.getByRole('menuitem', { name: 'PowerPoint (.pptx)' }).click(); + + await expect( + page.getByRole('button', { + name: 'Edit project name A revolução de IA integrada em navegadores', + }), + ).toBeVisible({ timeout: 90_000 }); + const warningDialog = page.getByRole('dialog', { name: 'PowerPoint font warnings' }); + if (await warningDialog.isVisible()) { + await warningDialog.getByRole('button', { name: 'Dismiss PowerPoint font warnings' }).click(); + } + await expect(page.getByText('1 / 59')).toBeVisible(); + + const frame = page.getByLabel('Slide canvas', { exact: true }); + const canvas = frame.locator('canvas').first(); + await expect + .poll(async () => { + const [red = 0, green = 0, blue = 0, alpha = 0] = await canvas.evaluate( + readCanvasPixel, + { x: 5, y: 5 }, + ); + return ( + Math.abs(red) <= 3 && + Math.abs(green - 205) <= 3 && + Math.abs(blue - 111) <= 3 && + alpha === 255 + ); + }) + .toBe(true); + await expect + .poll(async () => { + const [red = 0, green = 0, blue = 0, alpha = 0] = await canvas.evaluate( + readCanvasPixel, + { x: 190, y: 211 }, + ); + return ( + red > 120 && + green > 70 && + blue < 80 && + alpha === 255 + ); + }) + .toBe(true); + const sourcePortraitPixel = await canvas.evaluate(readCanvasPixel, { x: 190, y: 211 }); + await editor.openTool('Layout'); + const backgroundLayer = page.getByRole('button', { + name: 'Slide background — image47.png', + exact: true, + }); + await expect(backgroundLayer).toBeVisible(); + await backgroundLayer.click(); + await expect(backgroundLayer).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByRole('button', { name: 'BG Remover' })).toBeVisible(); + await frame.screenshot({ path: testInfo.outputPath('browser-ai-slide-1.png') }); + await page.keyboard.press('Escape'); + + await context.grantPermissions(['clipboard-read', 'clipboard-write'], { + origin: getServer().baseURL, + }); + await page.evaluate(() => navigator.clipboard.writeText('clipboard sentinel')); + await page + .getByRole('button', { name: 'Copy Slide 1 to clipboard' }) + .evaluate((button: HTMLButtonElement) => button.click()); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toContain('"objectUrl":"data:image/'); + const clipboardPayload = await page.evaluate(() => navigator.clipboard.readText()); + + await page.close(); + const destinationPage = await context.newPage(); + const destinationEditor = new EditorAppPage(destinationPage, getServer().baseURL); + await destinationEditor.gotoNewProject(); + await destinationPage.evaluate((payload) => { + const clipboardData = new DataTransfer(); + clipboardData.setData('text/plain', payload); + window.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, clipboardData })); + }, clipboardPayload); + + await expect(destinationPage.getByText('2 / 2')).toBeVisible(); + const pastedFrame = destinationPage.getByLabel('Slide canvas', { exact: true }); + const pastedCanvas = pastedFrame.locator('canvas').first(); + await expect + .poll(() => pastedCanvas.evaluate(readCanvasPixel, { x: 190, y: 211 })) + .toEqual(sourcePortraitPixel); + await pastedFrame.screenshot({ path: testInfo.outputPath('browser-ai-slide-1-pasted.png') }); + }); + test('imports PowerPoint and media fixtures, then exports a PowerPoint download', async ({ page, }, testInfo) => { @@ -36,9 +139,12 @@ test.describe('editor import and export journey', () => { await editor.openTool('Design'); const frame = page.getByTestId('slide-canvas-frame'); const canvas = frame.locator('canvas').first(); - const canvasBox = await canvas.boundingBox(); - expect(canvasBox).not.toBeNull(); - await page.mouse.click(canvasBox!.x + canvasBox!.width / 2, canvasBox!.y + canvasBox!.height / 2); + await expect + .poll(() => canvas.evaluate(readCanvasPixel, { x: 5, y: 5 })) + .toEqual([255, 255, 255, 255]); + await editor.openTool('Layout'); + await page.getByRole('button', { name: 'Page Background', exact: true }).click(); + await editor.openTool('Design'); await expect( page.getByRole('button', { name: 'Open layout picker, current layout Statement' }), ).toBeVisible(); diff --git a/tests/e2e/editor/local-persistence.spec.ts b/tests/e2e/editor/local-persistence.spec.ts index 0af8a93e..49f65422 100644 --- a/tests/e2e/editor/local-persistence.spec.ts +++ b/tests/e2e/editor/local-persistence.spec.ts @@ -86,6 +86,9 @@ test.describe('editor local persistence journey', () => { await page.goto(sourceUrl.toString()); await expect(page.getByRole('button', { name: 'Browser storage enabled' })).toBeVisible(); await page.getByRole('button', { name: 'Copy Source Slide to clipboard' }).click(); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 15_000 }) + .toContain('"objectUrl":"data:'); const clipboardPayload = await page.evaluate(() => navigator.clipboard.readText()); const destinationPage = await context.newPage(); @@ -108,26 +111,28 @@ test.describe('editor local persistence journey', () => { }, clipboardPayload); await expect - .poll(() => - destinationPage.evaluate(() => { - const prefix = 'localstudio.e2e.opfs.file:projects/Destination%20Deck/'; - const storedProject = window.localStorage.getItem(`${prefix}project.json`); - if (!storedProject) return undefined; - const project = JSON.parse(storedProject) as { - assets: Record; - pages: Array<{ background: { type: string; assetId?: string } }>; - }; - const pastedBackground = project.pages[1]?.background; - if (pastedBackground?.type !== 'asset' || !pastedBackground.assetId) return undefined; - const asset = project.assets[pastedBackground.assetId]; - if (!asset?.fileName) return undefined; - return { - bytes: window.localStorage.getItem(`${prefix}assets/${asset.fileName}`), - objectUrl: asset.objectUrl, - pageCount: project.pages.length, - storage: asset.storage, - }; - }), + .poll( + () => + destinationPage.evaluate(() => { + const prefix = 'localstudio.e2e.opfs.file:projects/Destination%20Deck/'; + const storedProject = window.localStorage.getItem(`${prefix}project.json`); + if (!storedProject) return undefined; + const project = JSON.parse(storedProject) as { + assets: Record; + pages: Array<{ background: { type: string; assetId?: string } }>; + }; + const pastedBackground = project.pages[1]?.background; + if (pastedBackground?.type !== 'asset' || !pastedBackground.assetId) return undefined; + const asset = project.assets[pastedBackground.assetId]; + if (!asset?.fileName) return undefined; + return { + bytes: window.localStorage.getItem(`${prefix}assets/${asset.fileName}`), + objectUrl: asset.objectUrl, + pageCount: project.pages.length, + storage: asset.storage, + }; + }), + { timeout: 15_000 }, ) .toEqual({ bytes: 'source-background-bytes', diff --git a/tests/e2e/editor/pptx-background-browser.ts b/tests/e2e/editor/pptx-background-browser.ts new file mode 100644 index 00000000..cd714706 --- /dev/null +++ b/tests/e2e/editor/pptx-background-browser.ts @@ -0,0 +1,8 @@ +export function readCanvasPixel( + canvas: HTMLCanvasElement, + point: { x: number; y: number }, +) { + const context = canvas.getContext('2d'); + if (!context) return []; + return Array.from(context.getImageData(point.x, point.y, 1, 1).data); +} diff --git a/tests/e2e/editor/service-contracts-editor-helpers.spec.ts b/tests/e2e/editor/service-contracts-editor-helpers.spec.ts index c24af187..59bf8f54 100644 --- a/tests/e2e/editor/service-contracts-editor-helpers.spec.ts +++ b/tests/e2e/editor/service-contracts-editor-helpers.spec.ts @@ -22,7 +22,7 @@ test('executes editor visual and crop helper contracts in the browser runtime', { height: 0.75, width: 0.75, x: 0.25, y: 0.25 }, ]); expect(result.normalizedCrop).toEqual({ height: 1, width: 1, x: 0, y: 0 }); - expect(result.opacities).toEqual([0.42, 0.25]); + expect(result.opacities).toEqual([0.42, undefined, 0.25]); expect(result.visualColors).toEqual(['#E0E0E0', '#334D66', '#ABCDEF']); expect(result.exportSummary).toBe( 'PowerPoint exported: 1 slide, 3 media items, 2 animation builds; 1 animation fallback, 1 transition fallback, 1 media fallback, 1 fallback.', diff --git a/tests/e2e/support/pptx-layout-media-parts.ts b/tests/e2e/support/pptx-layout-media-parts.ts index 043ed16c..6fdcb2d9 100644 --- a/tests/e2e/support/pptx-layout-media-parts.ts +++ b/tests/e2e/support/pptx-layout-media-parts.ts @@ -1,5 +1,12 @@ const tinyPngBytes = new Uint8Array([137, 80, 78, 71]); +const whitePixelPngBytes = Uint8Array.from( + Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64', + ), +); export const pptxLayoutMediaParts = [ + { path: 'ppt/media/background-image.png', contents: whitePixelPngBytes }, { path: 'ppt/media/layout-icon.png', contents: tinyPngBytes }, ] as const; diff --git a/tests/e2e/support/pptx-layout-slide-parts.ts b/tests/e2e/support/pptx-layout-slide-parts.ts index 080780cd..3d5284ca 100644 --- a/tests/e2e/support/pptx-layout-slide-parts.ts +++ b/tests/e2e/support/pptx-layout-slide-parts.ts @@ -1,19 +1,20 @@ const slideRels = ` + `; const slideXml = ` - + - + - Editable title + </ Editable title