278 lines
7.2 KiB
TypeScript
278 lines
7.2 KiB
TypeScript
/**
|
|
* 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<string, any>;
|
|
}
|
|
|
|
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<DesignFrame> }>;
|
|
framesToDelete: string[];
|
|
} {
|
|
const result = {
|
|
pagesToUpdate: [] as DesignPage[],
|
|
framesToAdd: [] as DesignFrame[],
|
|
framesToUpdate: [] as Array<{ id: string; updates: Partial<DesignFrame> }>,
|
|
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<string, ScribusFrameState>();
|
|
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<DesignFrame> = {};
|
|
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;
|
|
}
|