/** * SLA Bridge — bidirectional conversion between Automerge CRDT state and Scribus bridge commands. * * designDocToScribusCommands(doc) → array of bridge commands (for replaying state into Scribus) * scribusStateToDesignDocPatch(state, doc) → diff patches (for updating CRDT from Scribus changes) */ import type { DesignDoc, DesignFrame, DesignPage } from './schemas'; // ── Types from the bridge ── interface BridgeCommand { action: string; args: Record; } interface ScribusFrameState { name: string; type: string; // TextFrame, ImageFrame, Rectangle, Ellipse, etc. x: number; y: number; width: number; height: number; text?: string; fontSize?: number; fontName?: string; } interface ScribusPageState { number: number; width: number; height: number; } interface ScribusState { pages: ScribusPageState[]; frames: ScribusFrameState[]; } // ── CRDT → Scribus commands ── /** * Convert a full DesignDoc into an ordered list of bridge commands * to recreate the design in Scribus from scratch. */ export function designDocToScribusCommands(doc: DesignDoc): BridgeCommand[] { const commands: BridgeCommand[] = []; const pages = Object.values(doc.document.pages).sort((a, b) => a.number - b.number); // Create document with first page dimensions (or A4 default) const firstPage = pages[0]; commands.push({ action: 'new_document', args: { width: firstPage?.width || 210, height: firstPage?.height || 297, margins: firstPage?.margins || 10, pages: pages.length || 1, }, }); // Add frames sorted by page, then by creation time const frames = Object.values(doc.document.frames).sort((a, b) => { if (a.page !== b.page) return a.page - b.page; return (a.createdAt || 0) - (b.createdAt || 0); }); for (const frame of frames) { switch (frame.type) { case 'text': commands.push({ action: 'add_text_frame', args: { x: frame.x, y: frame.y, width: frame.width, height: frame.height, text: frame.text || '', fontSize: frame.fontSize || 12, fontName: frame.fontName || 'Liberation Sans', name: frame.id, }, }); break; case 'image': commands.push({ action: 'add_image_frame', args: { x: frame.x, y: frame.y, width: frame.width, height: frame.height, imagePath: frame.imagePath || '', name: frame.id, }, }); break; case 'rect': case 'ellipse': commands.push({ action: 'add_shape', args: { shapeType: frame.type === 'ellipse' ? 'ellipse' : 'rect', x: frame.x, y: frame.y, width: frame.width, height: frame.height, fill: frame.fill, name: frame.id, }, }); break; } } return commands; } /** * Convert a subset of changed commands (for incremental updates). * Only generates commands for frames that differ from existing state. */ export function designDocToIncrementalCommands( doc: DesignDoc, changedFrameIds: string[], ): BridgeCommand[] { const commands: BridgeCommand[] = []; for (const frameId of changedFrameIds) { const frame = doc.document.frames[frameId]; if (!frame) { // Frame was deleted commands.push({ action: 'delete_frame', args: { name: frameId } }); continue; } // For simplicity, delete and recreate the frame commands.push({ action: 'delete_frame', args: { name: frameId } }); switch (frame.type) { case 'text': commands.push({ action: 'add_text_frame', args: { x: frame.x, y: frame.y, width: frame.width, height: frame.height, text: frame.text || '', fontSize: frame.fontSize || 12, fontName: frame.fontName || 'Liberation Sans', name: frame.id, }, }); break; case 'image': commands.push({ action: 'add_image_frame', args: { x: frame.x, y: frame.y, width: frame.width, height: frame.height, imagePath: frame.imagePath || '', name: frame.id, }, }); break; case 'rect': case 'ellipse': commands.push({ action: 'add_shape', args: { shapeType: frame.type === 'ellipse' ? 'ellipse' : 'rect', x: frame.x, y: frame.y, width: frame.width, height: frame.height, fill: frame.fill, name: frame.id, }, }); break; } } return commands; } // ── Scribus → CRDT patches ── /** Map Scribus object type strings to our frame types. */ function mapScribusType(scribusType: string): DesignFrame['type'] { switch (scribusType) { case 'TextFrame': return 'text'; case 'ImageFrame': return 'image'; case 'Rectangle': return 'rect'; case 'Ellipse': return 'ellipse'; default: return 'rect'; // fallback } } /** * Diff Scribus state against CRDT doc and produce patches. * Returns arrays of frames to add, update, and delete in the CRDT. */ export function scribusStateToDesignDocPatch( scribusState: ScribusState, existingDoc: DesignDoc, ): { pagesToUpdate: DesignPage[]; framesToAdd: DesignFrame[]; framesToUpdate: Array<{ id: string; updates: Partial }>; framesToDelete: string[]; } { const result = { pagesToUpdate: [] as DesignPage[], framesToAdd: [] as DesignFrame[], framesToUpdate: [] as Array<{ id: string; updates: Partial }>, framesToDelete: [] as string[], }; // Pages for (const sp of scribusState.pages) { const pageId = `page_${sp.number}`; result.pagesToUpdate.push({ id: pageId, number: sp.number, width: sp.width, height: sp.height, margins: 10, // Scribus doesn't report margins in getAllObjects; keep default }); } // Frames — build a set of Scribus frame names const scribusFrameMap = new Map(); for (const sf of scribusState.frames) { scribusFrameMap.set(sf.name, sf); } const existingFrameIds = new Set(Object.keys(existingDoc.document.frames)); // Check for new or updated frames from Scribus for (const [name, sf] of scribusFrameMap) { const existing = existingDoc.document.frames[name]; const frameType = mapScribusType(sf.type); if (!existing) { // New frame in Scribus not in CRDT result.framesToAdd.push({ id: name, type: frameType, page: 1, // Scribus getAllObjects doesn't report per-frame page easily x: sf.x, y: sf.y, width: sf.width, height: sf.height, text: sf.text, fontSize: sf.fontSize, fontName: sf.fontName, }); } else { // Check for position/size/text changes const updates: Partial = {}; const tolerance = 0.5; // mm tolerance for position changes if (Math.abs(existing.x - sf.x) > tolerance) updates.x = sf.x; if (Math.abs(existing.y - sf.y) > tolerance) updates.y = sf.y; if (Math.abs(existing.width - sf.width) > tolerance) updates.width = sf.width; if (Math.abs(existing.height - sf.height) > tolerance) updates.height = sf.height; if (sf.text !== undefined && sf.text !== existing.text) updates.text = sf.text; if (sf.fontSize !== undefined && sf.fontSize !== existing.fontSize) updates.fontSize = sf.fontSize; if (Object.keys(updates).length > 0) { result.framesToUpdate.push({ id: name, updates }); } } } // Check for frames deleted in Scribus for (const frameId of existingFrameIds) { if (!scribusFrameMap.has(frameId)) { result.framesToDelete.push(frameId); } } return result; }