ducsvg 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,885 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ import { SVG_NS, arrayToMap, convertShapeToLinearElement, getBoundTextElement, getBoundTextElementPosition, getContainerElement, getContainingFrame, getDefaultLocalState, getElementAbsoluteCoords, getElementsSortedByZIndex, getFontFamilyString, getFrameLikeElements, getFreeDrawSvgPath, getLineHeightInPx, getPrecisionValueFromRaw, isArrowElement, isEllipseElement, isEmbeddableElement, isFrameLikeElement, isFreeDrawElement, isLinearElement, isRTL, isTableElement, isTextElement, uint8ArrayToBase64 } from "ducjs";
11
+ import { TEXT_ALIGN } from "ducjs/flatbuffers/duc";
12
+ import { renderLinearElementToSvg } from "ducsvg/utils/linearElementToSvg";
13
+ const DUC_STANDARD_PRIMARY_COLOR = "#7878dd";
14
+ const BACKGROUND_OPACITY = 0.1;
15
+ const AUX_STROKE_WIDTH_FACTOR = 0.2;
16
+ const COTA_STROKE_WIDTH_FACTOR = 0.4;
17
+ // const applyCadStandardStyling = (
18
+ // element: DucElement,
19
+ // appState: Partial<AppState>,
20
+ // currentScope: Scope,
21
+ // ): DucElement => {
22
+ // const standard = appState.standard;
23
+ // const subset = element.subset;
24
+ // if (!subset || !standard) {
25
+ // return element;
26
+ // }
27
+ // const shouldApply = (standard: DesignStandard, subset?: ElementSubset): boolean => {
28
+ // if (!subset) {
29
+ // return false;
30
+ // }
31
+ // return standard === DESIGN_STANDARD.DUC;
32
+ // };
33
+ // if (!shouldApply(standard, subset)) {
34
+ // return element;
35
+ // }
36
+ // const newElement = element;
37
+ // if (standard === DESIGN_STANDARD.DUC) {
38
+ // if (newElement.stroke) {
39
+ // newElement.stroke.forEach((stroke) => {
40
+ // if (subset === ELEMENT_SUBSET.AUX) {
41
+ // stroke.content.src = DUC_STANDARD_PRIMARY_COLOR;
42
+ // if (stroke.width) {
43
+ // stroke.width = getPrecisionValueFromRaw((stroke.width.value * AUX_STROKE_WIDTH_FACTOR) as RawValue, element.scope, currentScope);
44
+ // }
45
+ // stroke.style.join = STROKE_JOIN.round;
46
+ // stroke.style.cap = STROKE_CAP.round;
47
+ // } else if (subset === ELEMENT_SUBSET.COTA) {
48
+ // stroke.content.src = DUC_STANDARD_PRIMARY_COLOR;
49
+ // if (stroke.width) {
50
+ // stroke.width = getPrecisionValueFromRaw((stroke.width.value * COTA_STROKE_WIDTH_FACTOR) as RawValue, element.scope, currentScope);
51
+ // }
52
+ // stroke.style.join = STROKE_JOIN.round;
53
+ // stroke.style.cap = STROKE_CAP.round;
54
+ // }
55
+ // });
56
+ // }
57
+ // if (newElement.background) {
58
+ // newElement.background.forEach((background) => {
59
+ // if (subset === ELEMENT_SUBSET.AUX || subset === ELEMENT_SUBSET.COTA) {
60
+ // background.content.src = DUC_STANDARD_PRIMARY_COLOR;
61
+ // background.content.opacity = BACKGROUND_OPACITY;
62
+ // }
63
+ // });
64
+ // }
65
+ // }
66
+ // return newElement;
67
+ // };
68
+ // Helper function to get frame rendering configuration
69
+ // const getFrameRenderingConfig = (
70
+ // exportingFrame: DucFrameLikeElement | null,
71
+ // frameRendering: AppState["frameRendering"] | null,
72
+ // ): AppState["frameRendering"] => {
73
+ // frameRendering = frameRendering || getDefaultAppState().frameRendering;
74
+ // return {
75
+ // enabled: exportingFrame ? true : frameRendering.enabled,
76
+ // outline: exportingFrame ? false : frameRendering.outline,
77
+ // name: exportingFrame ? false : frameRendering.name,
78
+ // clip: exportingFrame ? true : frameRendering.clip,
79
+ // };
80
+ // };
81
+ // Main export function to convert DUC data to SVG string
82
+ export const ducToSvg = (elements, localState, globalState, files, opts) => __awaiter(void 0, void 0, void 0, function* () {
83
+ const currentScope = localState.scope;
84
+ // Get frame rendering configuration
85
+ // const frameRendering = getFrameRenderingConfig(
86
+ // opts?.exportingFrame ?? null,
87
+ // opts?.frameRendering ?? appState?.frameRendering ?? null,
88
+ // );
89
+ const frameRendering = {
90
+ enabled: true,
91
+ outline: false,
92
+ name: false,
93
+ clip: true,
94
+ };
95
+ const ducState = Object.assign(Object.assign({}, localState), globalState);
96
+ // Filter out deleted elements
97
+ const elementsForRender = elements.filter(el => !el.isDeleted);
98
+ // Create SVG document
99
+ const svgDocument = document.createElementNS(SVG_NS, "svg");
100
+ // Get the bounds of all elements to set SVG dimensions
101
+ const bounds = getElementsBounds(elementsForRender);
102
+ // Set SVG properties
103
+ svgDocument.setAttribute("xmlns", SVG_NS);
104
+ svgDocument.setAttribute("width", `${bounds.width}`);
105
+ svgDocument.setAttribute("height", `${bounds.height}`);
106
+ svgDocument.setAttribute("viewBox", `${bounds.minX} ${bounds.minY} ${bounds.width} ${bounds.height}`);
107
+ // Add metadata from appState
108
+ addMetadata(svgDocument, ducState, elementsForRender.length);
109
+ // Sort elements by z-index
110
+ const sortedElements = getElementsSortedByZIndex(elementsForRender);
111
+ // Create a map of elements for reference
112
+ const elementsMap = arrayToMap(elementsForRender);
113
+ // Create defs for clipping paths and markers
114
+ const defs = document.createElementNS(SVG_NS, "defs");
115
+ svgDocument.appendChild(defs);
116
+ // Create frame clip paths
117
+ const frameElements = getFrameLikeElements(elementsForRender);
118
+ for (const frame of frameElements) {
119
+ if (frameRendering.clip && frame.clip) {
120
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap, currentScope);
121
+ const cx = (x2 - x1) / 2 - (frame.x.scoped - x1);
122
+ const cy = (y2 - y1) / 2 - (frame.y.scoped - y1);
123
+ const clipPath = document.createElementNS(SVG_NS, "clipPath");
124
+ clipPath.setAttribute("id", frame.id);
125
+ clipPath.setAttribute("transform", `translate(${frame.x.scoped} ${frame.y.scoped}) rotate(${(180 * frame.angle) / Math.PI} ${cx} ${cy})`);
126
+ const clipRect = document.createElementNS(SVG_NS, "rect");
127
+ clipRect.setAttribute("x", "0");
128
+ clipRect.setAttribute("y", "0");
129
+ clipRect.setAttribute("width", frame.width.scoped.toString());
130
+ clipRect.setAttribute("height", frame.height.scoped.toString());
131
+ if (frame.roundness && frame.roundness.scoped > 0) {
132
+ clipRect.setAttribute("rx", frame.roundness.scoped.toString());
133
+ clipRect.setAttribute("ry", frame.roundness.scoped.toString());
134
+ }
135
+ clipPath.appendChild(clipRect);
136
+ defs.appendChild(clipPath);
137
+ }
138
+ }
139
+ // TODO: in the future, we will need to inline the fonts with a proper Font manager system
140
+ // const fontFamilies = elements.reduce((acc, element) => {
141
+ // if (isTextElement(element)) {
142
+ // acc.add(element.fontFamily);
143
+ // }
144
+ // return acc;
145
+ // }, new Set<number>());
146
+ // const fontFaces = opts?.skipInliningFonts
147
+ // ? []
148
+ // : await Promise.all(
149
+ // Array.from(fontFamilies).map(async (x) => {
150
+ // const fontData = Fonts.registered.get(x);
151
+ // if (!fontData || !Array.isArray(fontData.fonts)) {
152
+ // console.error(
153
+ // `Couldn't find registered fonts for font-family "${x}"`,
154
+ // Fonts.registered,
155
+ // );
156
+ // return;
157
+ // }
158
+ // if (fontData.metadata?.local) {
159
+ // // don't inline local fonts
160
+ // return;
161
+ // }
162
+ // return Promise.all(
163
+ // fontData.fonts.map(
164
+ // async (font) => `@font-face {
165
+ // font-family: "${font.fontFace.family}";
166
+ // src: url(${await font.getContent()});
167
+ // font-style: ${font.fontFace.style};
168
+ // font-weight: ${font.fontFace.weight};
169
+ // font-display: swap;
170
+ // }`,
171
+ // ),
172
+ // );
173
+ // }),
174
+ // );
175
+ // if (fontFaces.length > 0) {
176
+ // const style = document.createElementNS(SVG_NS, "style");
177
+ // style.textContent = fontFaces.flat().filter(Boolean).join("\n");
178
+ // defs.appendChild(style);
179
+ // }
180
+ // Group elements by frame for proper clipping
181
+ const elementsByFrame = new Map();
182
+ // Group elements by their frameId
183
+ sortedElements.forEach(element => {
184
+ // Skip bound text elements - they'll be rendered with their containers
185
+ if (isTextElement(element) && element.containerId && elementsMap.has(element.containerId)) {
186
+ return;
187
+ }
188
+ const frameId = isFrameLikeElement(element) ? null : element.frameId;
189
+ if (!elementsByFrame.has(frameId)) {
190
+ elementsByFrame.set(frameId, []);
191
+ }
192
+ elementsByFrame.get(frameId).push(element);
193
+ });
194
+ // Render frame elements first (backgrounds)
195
+ frameElements.forEach(frame => {
196
+ if (frame.isDeleted || !frame.isVisible)
197
+ return;
198
+ const frameGroup = renderElementToSvg(frame, elementsMap, ducState, files, defs, currentScope);
199
+ if (frameGroup) {
200
+ svgDocument.appendChild(frameGroup);
201
+ }
202
+ });
203
+ // Render elements grouped by frame for proper clipping
204
+ elementsByFrame.forEach((elements, frameId) => {
205
+ if (frameId && frameRendering.enabled && frameRendering.clip) {
206
+ // Create a group for the frame's children with clipping
207
+ const frameChildrenGroup = document.createElementNS(SVG_NS, "g");
208
+ frameChildrenGroup.setAttribute("clip-path", `url(#${frameId})`);
209
+ // Render frame children
210
+ elements
211
+ .filter((el) => !isEmbeddableElement(el))
212
+ .forEach(element => {
213
+ if (element.isVisible) {
214
+ const elementNode = renderElementToSvg(element, elementsMap, ducState, files, defs, currentScope);
215
+ if (elementNode) {
216
+ frameChildrenGroup.appendChild(elementNode);
217
+ }
218
+ // Render bound text element if it exists
219
+ const boundTextElement = getBoundTextElement(element, elementsMap);
220
+ if (boundTextElement) {
221
+ const boundTextGroup = renderElementToSvg(boundTextElement, elementsMap, ducState, files, defs, currentScope);
222
+ if (boundTextGroup) {
223
+ frameChildrenGroup.appendChild(boundTextGroup);
224
+ }
225
+ }
226
+ }
227
+ });
228
+ svgDocument.appendChild(frameChildrenGroup);
229
+ // Render iframe-like elements for this frame on top
230
+ elements
231
+ .filter((el) => isEmbeddableElement(el))
232
+ .forEach(element => {
233
+ if (element.isVisible) {
234
+ const elementNode = renderElementToSvg(element, elementsMap, ducState, files, defs, currentScope);
235
+ if (elementNode) {
236
+ frameChildrenGroup.appendChild(elementNode);
237
+ }
238
+ }
239
+ });
240
+ }
241
+ else {
242
+ // Render elements without frame clipping
243
+ elements
244
+ .filter((el) => !isEmbeddableElement(el))
245
+ .forEach(element => {
246
+ if (element.isVisible) {
247
+ const elementNode = renderElementToSvg(element, elementsMap, ducState, files, defs, currentScope);
248
+ if (elementNode) {
249
+ svgDocument.appendChild(elementNode);
250
+ }
251
+ // Render bound text element if it exists
252
+ const boundTextElement = getBoundTextElement(element, elementsMap);
253
+ if (boundTextElement) {
254
+ const boundTextGroup = renderElementToSvg(boundTextElement, elementsMap, ducState, files, defs, currentScope);
255
+ if (boundTextGroup) {
256
+ svgDocument.appendChild(boundTextGroup);
257
+ }
258
+ }
259
+ }
260
+ });
261
+ // Render iframe-like elements on top
262
+ elements
263
+ .filter((el) => isEmbeddableElement(el))
264
+ .forEach(element => {
265
+ if (element.isVisible) {
266
+ const elementNode = renderElementToSvg(element, elementsMap, ducState, files, defs, currentScope);
267
+ if (elementNode) {
268
+ svgDocument.appendChild(elementNode);
269
+ }
270
+ }
271
+ });
272
+ }
273
+ });
274
+ // Convert to string
275
+ const serializer = new XMLSerializer();
276
+ const svgString = serializer.serializeToString(svgDocument);
277
+ return svgString;
278
+ });
279
+ // Helper function to get bounds of all elements
280
+ const getElementsBounds = (elements) => {
281
+ if (elements.length === 0) {
282
+ return { minX: 0, minY: 0, maxX: 0, maxY: 0, width: 0, height: 0 };
283
+ }
284
+ const visibleElements = elements.filter(el => !el.isDeleted && el.isVisible);
285
+ if (visibleElements.length === 0) {
286
+ return { minX: 0, minY: 0, maxX: 0, maxY: 0, width: 0, height: 0 };
287
+ }
288
+ let minX = Infinity;
289
+ let minY = Infinity;
290
+ let maxX = -Infinity;
291
+ let maxY = -Infinity;
292
+ visibleElements.forEach(element => {
293
+ // Calculate element bounds based on position, dimensions, and rotation
294
+ const { x, y, width, height, angle } = element;
295
+ // For elements with points (linear, arrow, freedraw), we need to check each point
296
+ if (isLinearElement(element) || isArrowElement(element) || isFreeDrawElement(element)) {
297
+ element.points.forEach(point => {
298
+ const px = element.x.scoped + point.x.scoped;
299
+ const py = element.y.scoped + point.y.scoped;
300
+ minX = Math.min(minX, px);
301
+ minY = Math.min(minY, py);
302
+ maxX = Math.max(maxX, px);
303
+ maxY = Math.max(maxY, py);
304
+ });
305
+ }
306
+ else {
307
+ // For rectangle-based elements
308
+ const corners = getRotatedElementCorners(x.scoped, y.scoped, width.scoped, height.scoped, angle);
309
+ corners.forEach(corner => {
310
+ minX = Math.min(minX, corner.x);
311
+ minY = Math.min(minY, corner.y);
312
+ maxX = Math.max(maxX, corner.x);
313
+ maxY = Math.max(maxY, corner.y);
314
+ });
315
+ }
316
+ });
317
+ // Add padding
318
+ const padding = 10;
319
+ minX -= padding;
320
+ minY -= padding;
321
+ maxX += padding;
322
+ maxY += padding;
323
+ return {
324
+ minX,
325
+ minY,
326
+ maxX,
327
+ maxY,
328
+ width: maxX - minX,
329
+ height: maxY - minY
330
+ };
331
+ };
332
+ // Helper to get corners of a rotated rectangle
333
+ const getRotatedElementCorners = (x, y, width, height, angle) => {
334
+ const centerX = x + width / 2;
335
+ const centerY = y + height / 2;
336
+ const corners = [
337
+ { x, y },
338
+ { x: x + width, y },
339
+ { x: x + width, y: y + height },
340
+ { x, y: y + height }
341
+ ];
342
+ if (angle === 0) {
343
+ return corners;
344
+ }
345
+ return corners.map(corner => {
346
+ const dx = corner.x - centerX;
347
+ const dy = corner.y - centerY;
348
+ const cos = Math.cos(angle);
349
+ const sin = Math.sin(angle);
350
+ return {
351
+ x: centerX + dx * cos - dy * sin,
352
+ y: centerY + dx * sin + dy * cos
353
+ };
354
+ });
355
+ };
356
+ // FIXME: This does not seem complete
357
+ const addMetadata = (svgRoot, ducState, elementCount) => {
358
+ const defaultState = getDefaultLocalState();
359
+ const state = Object.assign(Object.assign({}, defaultState), ducState);
360
+ // Add metadata
361
+ svgRoot.setAttribute("data-duc-elements", `${elementCount}`);
362
+ if (state.viewBackgroundColor) {
363
+ svgRoot.setAttribute("data-duc-background", state.viewBackgroundColor);
364
+ }
365
+ if (state.scope) {
366
+ svgRoot.setAttribute("data-duc-scope", state.scope);
367
+ }
368
+ if (state.name) {
369
+ svgRoot.setAttribute("data-duc-name", state.name);
370
+ }
371
+ // Add custom namespace for duc metadata
372
+ svgRoot.setAttribute("xmlns:duc", "https://duc.ducflair.com/xmlns");
373
+ // Create metadata section
374
+ const metadataElement = document.createElementNS(SVG_NS, "metadata");
375
+ metadataElement.setAttribute("id", "duc-metadata");
376
+ // Add relevant app state properties as metadata
377
+ const relevantStateProps = [
378
+ "gridSize",
379
+ "gridStep",
380
+ // "gridModeEnabled",
381
+ "viewBackgroundColor",
382
+ "scope",
383
+ "mainScope",
384
+ ];
385
+ // Type-safe implementation for adding metadata
386
+ relevantStateProps.forEach(prop => {
387
+ const value = state[prop];
388
+ if (value !== undefined) {
389
+ const valueStr = typeof value === "object"
390
+ ? JSON.stringify(value)
391
+ : String(value);
392
+ metadataElement.setAttribute(`duc:${prop}`, valueStr);
393
+ }
394
+ });
395
+ svgRoot.appendChild(metadataElement);
396
+ };
397
+ const renderCrosshair = (element, renderedElement, angle, // radians
398
+ rotCx, rotCy) => {
399
+ const group = document.createElementNS(SVG_NS, "g");
400
+ const base = element;
401
+ // Center of original ellipse in absolute coords
402
+ const originalCenterX = base.x.scoped + base.width.scoped / 2;
403
+ const originalCenterY = base.y.scoped + base.height.scoped / 2;
404
+ // Top-left of the rendered element's group in absolute coords
405
+ const renderedX = renderedElement.x.scoped;
406
+ const renderedY = renderedElement.y.scoped;
407
+ // The position of the original center in the un-rotated group's coordinate system
408
+ // this would be the target position IF there were no rotation.
409
+ const targetX = originalCenterX - renderedX;
410
+ const targetY = originalCenterY - renderedY;
411
+ // Since the group is rotated, we must apply the inverse rotation to our
412
+ // target coordinates to find where to place the crosshair before rotation.
413
+ const cos = Math.cos(-angle);
414
+ const sin = Math.sin(-angle);
415
+ const dx = targetX - rotCx;
416
+ const dy = targetY - rotCy;
417
+ const cx = rotCx + (dx * cos - dy * sin);
418
+ const cy = rotCy + (dx * sin + dy * cos);
419
+ const crossWidth = base.width.scoped * 1.2;
420
+ const crossHeight = base.height.scoped * 1.2;
421
+ const x1 = cx - crossWidth / 2;
422
+ const y1 = cy - crossHeight / 2;
423
+ const x2 = cx + crossWidth / 2;
424
+ const y2 = cy + crossHeight / 2;
425
+ const main_dash_len = 26.0;
426
+ const pattern = [main_dash_len, 6.0, 0.6, 6.0];
427
+ const pattern_len = pattern.reduce((a, b) => a + b, 0);
428
+ const hLine = document.createElementNS(SVG_NS, "line");
429
+ hLine.setAttribute("x1", `${x1}`);
430
+ hLine.setAttribute("y1", `${cy}`);
431
+ hLine.setAttribute("x2", `${x2}`);
432
+ hLine.setAttribute("y2", `${cy}`);
433
+ hLine.setAttribute("stroke", DUC_STANDARD_PRIMARY_COLOR);
434
+ hLine.setAttribute("stroke-width", "1");
435
+ hLine.setAttribute("stroke-dasharray", pattern.join(" "));
436
+ let offset = (main_dash_len / 2.0 - crossWidth / 2.0) % pattern_len;
437
+ if (offset < 0) {
438
+ offset += pattern_len;
439
+ }
440
+ hLine.setAttribute("stroke-dashoffset", `${offset}`);
441
+ group.appendChild(hLine);
442
+ const vLine = document.createElementNS(SVG_NS, "line");
443
+ vLine.setAttribute("x1", `${cx}`);
444
+ vLine.setAttribute("y1", `${y1}`);
445
+ vLine.setAttribute("x2", `${cx}`);
446
+ vLine.setAttribute("y2", `${y2}`);
447
+ vLine.setAttribute("stroke", DUC_STANDARD_PRIMARY_COLOR);
448
+ vLine.setAttribute("stroke-width", "1");
449
+ vLine.setAttribute("stroke-dasharray", pattern.join(" "));
450
+ offset = (main_dash_len / 2.0 - crossHeight / 2.0) % pattern_len;
451
+ if (offset < 0) {
452
+ offset += pattern_len;
453
+ }
454
+ vLine.setAttribute("stroke-dashoffset", `${offset}`);
455
+ group.appendChild(vLine);
456
+ return group;
457
+ };
458
+ // Main function to render an element to SVG (adapted from staticSvgScene.ts)
459
+ export const renderElementToSvg = (_element, elementsMap, ducState, files, defs, currentScope) => {
460
+ var _a, _b;
461
+ let element = _element;
462
+ if (isEllipseElement(element)) {
463
+ const converted = convertShapeToLinearElement(currentScope, element);
464
+ if (converted) {
465
+ element = converted;
466
+ }
467
+ }
468
+ // element = applyCadStandardStyling(element, appState, currentScope);
469
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap, currentScope);
470
+ let cx = (x2 - x1) / 2 - (element.x.scoped - x1);
471
+ let cy = (y2 - y1) / 2 - (element.y.scoped - y1);
472
+ // Handle text element positioning for bound text
473
+ let offsetX = 0;
474
+ let offsetY = 0;
475
+ if (isTextElement(element)) {
476
+ const container = getContainerElement(element, elementsMap);
477
+ if (isArrowElement(container)) {
478
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(container, elementsMap, currentScope);
479
+ const boundTextCoords = getBoundTextElementPosition(container, element, elementsMap, currentScope);
480
+ if (!boundTextCoords) {
481
+ return null;
482
+ }
483
+ cx = (x2 - x1) / 2 - (boundTextCoords.x - x1);
484
+ cy = (y2 - y1) / 2 - (boundTextCoords.y - y1);
485
+ offsetX = offsetX + boundTextCoords.x - element.x.scoped;
486
+ offsetY = offsetY + boundTextCoords.y - element.y.scoped;
487
+ }
488
+ }
489
+ const degree = (180 * element.angle) / Math.PI;
490
+ // element to append node to, most of the time will be the main group
491
+ let root;
492
+ // Group to hold the element and any transformations
493
+ const group = document.createElementNS(SVG_NS, "g");
494
+ root = group;
495
+ // if the element has a link, create an anchor tag and make that the new root
496
+ if (element.link) {
497
+ const anchorTag = document.createElementNS(SVG_NS, "a");
498
+ anchorTag.setAttribute("href", element.link);
499
+ group.appendChild(anchorTag);
500
+ root = anchorTag;
501
+ }
502
+ const addToRoot = (node, element, originalElement) => {
503
+ // if (isTestEnv()) {
504
+ // node.setAttribute("data-id", element.id);
505
+ // }
506
+ root.appendChild(node);
507
+ if (isEllipseElement(originalElement) && originalElement.showAuxCrosshair) {
508
+ const crosshair = renderCrosshair(originalElement, element, element.angle, cx, cy);
509
+ root.appendChild(crosshair);
510
+ }
511
+ };
512
+ // Calculate opacity including frame opacity
513
+ const opacity = ((_b = (_a = getContainingFrame(element, elementsMap)) === null || _a === void 0 ? void 0 : _a.opacity) !== null && _b !== void 0 ? _b : 1) * element.opacity;
514
+ // Apply transformations and opacity to the group
515
+ group.setAttribute("transform", `translate(${element.x.scoped + offsetX} ${element.y.scoped + offsetY}) rotate(${degree} ${cx} ${cy})`);
516
+ if (opacity !== 1) {
517
+ group.setAttribute("opacity", opacity.toString());
518
+ }
519
+ // Handle element type-specific rendering with improved logic from staticSvgScene.ts
520
+ switch (element.type) {
521
+ case "rectangle": {
522
+ const rect = renderRectangle(element);
523
+ addToRoot(rect, element, _element);
524
+ break;
525
+ }
526
+ case "polygon": {
527
+ const polygon = renderPolygon(element);
528
+ addToRoot(polygon, element, _element);
529
+ break;
530
+ }
531
+ case "line":
532
+ case "arrow": {
533
+ const result = renderLinearElementToSvg(element, elementsMap, ducState, files, defs, currentScope, offsetX, offsetY);
534
+ if (result.mask) {
535
+ defs.appendChild(result.mask);
536
+ }
537
+ addToRoot(result.element, element, _element);
538
+ break;
539
+ }
540
+ case "freedraw": {
541
+ const path = renderFreeDraw(element);
542
+ addToRoot(path, element, _element);
543
+ break;
544
+ }
545
+ case "image": {
546
+ const imageEl = renderImage(element, files, defs);
547
+ if (imageEl) {
548
+ addToRoot(imageEl, element, _element);
549
+ }
550
+ break;
551
+ }
552
+ case "text": {
553
+ const textEl = renderText(element, currentScope);
554
+ addToRoot(textEl, element, _element);
555
+ break;
556
+ }
557
+ case "frame":
558
+ case "embeddable": {
559
+ const iframeEl = renderIframe(element);
560
+ addToRoot(iframeEl, element, _element);
561
+ break;
562
+ }
563
+ default: {
564
+ // Handle other element types
565
+ if (isTableElement(element)) {
566
+ const tableEl = renderTable(element);
567
+ addToRoot(tableEl, element, _element);
568
+ }
569
+ else if (element.type === "doc") {
570
+ const docEl = renderDoc(element);
571
+ addToRoot(docEl, element, _element);
572
+ }
573
+ break;
574
+ }
575
+ }
576
+ return group;
577
+ };
578
+ // Helper to apply styles (stroke and fill)
579
+ export const applyStyles = (element, strokes, backgrounds) => {
580
+ // Apply backgrounds (fills)
581
+ if (backgrounds.length > 0) {
582
+ const background = backgrounds[0]; // Use the first background for now
583
+ if (background.content.visible) {
584
+ element.setAttribute("fill", background.content.src);
585
+ if (background.content.opacity < 1) {
586
+ element.setAttribute("fill-opacity", background.content.opacity.toString());
587
+ }
588
+ }
589
+ else {
590
+ element.setAttribute("fill", "none");
591
+ }
592
+ }
593
+ else {
594
+ element.setAttribute("fill", "none");
595
+ }
596
+ // Apply strokes
597
+ if (strokes.length > 0) {
598
+ const stroke = strokes[0]; // Use the first stroke for now
599
+ if (stroke.content.visible) {
600
+ element.setAttribute("stroke", stroke.content.src);
601
+ element.setAttribute("stroke-width", stroke.width.scoped.toString());
602
+ // Apply stroke opacity
603
+ if (stroke.content.opacity < 1) {
604
+ element.setAttribute("stroke-opacity", stroke.content.opacity.toString());
605
+ }
606
+ // Apply stroke style (dash, cap, join)
607
+ if (stroke.style.dash && stroke.style.dash.length > 0) {
608
+ element.setAttribute("stroke-dasharray", stroke.style.dash.join(" "));
609
+ }
610
+ if (stroke.style.cap) {
611
+ let capStyle = "butt";
612
+ const capType = String(stroke.style.cap);
613
+ if (capType === "round") {
614
+ capStyle = "round";
615
+ }
616
+ else if (capType === "square") {
617
+ capStyle = "square";
618
+ }
619
+ element.setAttribute("stroke-linecap", capStyle);
620
+ }
621
+ if (stroke.style.join) {
622
+ let joinStyle = "miter";
623
+ const joinType = String(stroke.style.join);
624
+ if (joinType === "round") {
625
+ joinStyle = "round";
626
+ }
627
+ else if (joinType === "bevel") {
628
+ joinStyle = "bevel";
629
+ }
630
+ element.setAttribute("stroke-linejoin", joinStyle);
631
+ }
632
+ if (stroke.style.miterLimit) {
633
+ element.setAttribute("stroke-miterlimit", stroke.style.miterLimit.toString());
634
+ }
635
+ }
636
+ else {
637
+ element.setAttribute("stroke", "none");
638
+ }
639
+ }
640
+ else {
641
+ element.setAttribute("stroke", "none");
642
+ }
643
+ };
644
+ // Render rectangle
645
+ const renderRectangle = (element) => {
646
+ const rect = document.createElementNS(SVG_NS, "rect");
647
+ rect.setAttribute("width", element.width.scoped.toString());
648
+ rect.setAttribute("height", element.height.scoped.toString());
649
+ // Apply rounded corners if needed
650
+ if (element.roundness && element.roundness.scoped > 0) {
651
+ rect.setAttribute("rx", element.roundness.scoped.toString());
652
+ rect.setAttribute("ry", element.roundness.scoped.toString());
653
+ }
654
+ // Apply styles
655
+ applyStyles(rect, element.stroke, element.background);
656
+ return rect;
657
+ };
658
+ // Render polygon
659
+ const renderPolygon = (element) => {
660
+ // Create polygon element
661
+ const polygon = document.createElementNS(SVG_NS, "polygon");
662
+ // Calculate polygon points
663
+ const sides = element.sides;
664
+ const width = element.width.scoped;
665
+ const height = element.height.scoped;
666
+ const centerX = width / 2;
667
+ const centerY = height / 2;
668
+ const points = [];
669
+ for (let i = 0; i < sides; i++) {
670
+ const angle = (i * 2 * Math.PI) / sides - Math.PI / 2;
671
+ const x = centerX + (width / 2) * Math.cos(angle);
672
+ const y = centerY + (height / 2) * Math.sin(angle);
673
+ points.push({ x, y });
674
+ }
675
+ // Set points attribute
676
+ polygon.setAttribute("points", points.map(p => `${p.x},${p.y}`).join(" "));
677
+ // Apply styles
678
+ applyStyles(polygon, element.stroke, element.background);
679
+ return polygon;
680
+ };
681
+ // Render text element (copied and adapted from staticSvgScene.ts)
682
+ const renderText = (element, currentScope) => {
683
+ const node = document.createElementNS(SVG_NS, "g");
684
+ const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
685
+ const lineHeightPx = getLineHeightInPx(element.fontSize, element.lineHeight);
686
+ const horizontalOffset = element.textAlign === TEXT_ALIGN.CENTER
687
+ ? (element.width.value / 2)
688
+ : element.textAlign === TEXT_ALIGN.RIGHT
689
+ ? (element.width.value)
690
+ : 0;
691
+ // const verticalOffset = getVerticalOffset(
692
+ // element.fontFamily,
693
+ // element.fontSize,
694
+ // lineHeightPx,
695
+ // );
696
+ // TODO: in the future, we will need to calculate the vertical offset for text elements with the right font metrics
697
+ const verticalOffset = 0;
698
+ const verticalOffsetScoped = getPrecisionValueFromRaw(verticalOffset, element.scope, currentScope).scoped;
699
+ const horizontalOffsetScoped = getPrecisionValueFromRaw(horizontalOffset, element.scope, currentScope).scoped;
700
+ const lineHeightPxScoped = getPrecisionValueFromRaw(lineHeightPx, element.scope, currentScope).scoped;
701
+ const direction = isRTL(element.text) ? "rtl" : "ltr";
702
+ const textAnchor = element.textAlign === TEXT_ALIGN.CENTER
703
+ ? "middle"
704
+ : element.textAlign === TEXT_ALIGN.RIGHT || direction === "rtl"
705
+ ? "end"
706
+ : "start";
707
+ for (let i = 0; i < lines.length; i++) {
708
+ const text = document.createElementNS(SVG_NS, "text");
709
+ text.textContent = lines[i];
710
+ text.setAttribute("x", `${horizontalOffsetScoped}`);
711
+ text.setAttribute("y", `${i * lineHeightPxScoped + verticalOffsetScoped}`);
712
+ text.setAttribute("font-family", getFontFamilyString(element));
713
+ text.setAttribute("font-size", `${element.fontSize.scoped}px`);
714
+ text.setAttribute("fill", element.stroke[0].content.src);
715
+ text.setAttribute("text-anchor", textAnchor);
716
+ text.setAttribute("style", "white-space: pre;");
717
+ text.setAttribute("direction", direction);
718
+ text.setAttribute("dominant-baseline", "alphabetic");
719
+ node.appendChild(text);
720
+ }
721
+ return node;
722
+ };
723
+ // Render image element
724
+ const renderImage = (element, files, defs) => {
725
+ const width = Math.round(element.width.scoped);
726
+ const height = Math.round(element.height.scoped);
727
+ const fileData = element.fileId && files[element.fileId];
728
+ if (!fileData) {
729
+ // Return a placeholder rectangle if no file data
730
+ const rect = document.createElementNS(SVG_NS, "rect");
731
+ rect.setAttribute("width", width.toString());
732
+ rect.setAttribute("height", height.toString());
733
+ rect.setAttribute("fill", "#f0f0f0");
734
+ rect.setAttribute("stroke", "#ccc");
735
+ return rect;
736
+ }
737
+ // Create symbol for reuse (copied approach from staticSvgScene.ts)
738
+ const symbolId = `image-${fileData.id}`;
739
+ let symbol = defs.querySelector(`#${symbolId}`);
740
+ if (!symbol) {
741
+ symbol = document.createElementNS(SVG_NS, "symbol");
742
+ symbol.id = symbolId;
743
+ const image = document.createElementNS(SVG_NS, "image");
744
+ image.setAttribute("width", "100%");
745
+ image.setAttribute("height", "100%");
746
+ image.setAttribute("href", uint8ArrayToBase64(fileData.data));
747
+ symbol.appendChild(image);
748
+ defs.appendChild(symbol);
749
+ }
750
+ const use = document.createElementNS(SVG_NS, "use");
751
+ use.setAttribute("href", `#${symbolId}`);
752
+ use.setAttribute("width", `${width}`);
753
+ use.setAttribute("height", `${height}`);
754
+ // Handle scaling/flipping (copied approach from staticSvgScene.ts)
755
+ if (element.scaleFlip && (element.scaleFlip[0] !== 1 || element.scaleFlip[1] !== 1)) {
756
+ const translateX = element.scaleFlip[0] !== 1 ? -width : 0;
757
+ const translateY = element.scaleFlip[1] !== 1 ? -height : 0;
758
+ use.setAttribute("transform", `scale(${element.scaleFlip[0]}, ${element.scaleFlip[1]}) translate(${translateX} ${translateY})`);
759
+ }
760
+ const g = document.createElementNS(SVG_NS, "g");
761
+ g.appendChild(use);
762
+ // Handle roundness with clipping (copied approach from staticSvgScene.ts)
763
+ if (element.roundness) {
764
+ const clipPath = document.createElementNS(SVG_NS, "clipPath");
765
+ clipPath.id = `image-clipPath-${element.id}`;
766
+ const clipRect = document.createElementNS(SVG_NS, "rect");
767
+ const radius = element.roundness.scoped;
768
+ clipRect.setAttribute("width", `${element.width.scoped}`);
769
+ clipRect.setAttribute("height", `${element.height.scoped}`);
770
+ clipRect.setAttribute("rx", `${radius}`);
771
+ clipRect.setAttribute("ry", `${radius}`);
772
+ clipPath.appendChild(clipRect);
773
+ defs.appendChild(clipPath);
774
+ g.setAttributeNS(SVG_NS, "clip-path", `url(#${clipPath.id})`);
775
+ }
776
+ return g;
777
+ };
778
+ // Render freedraw element
779
+ const renderFreeDraw = (element) => {
780
+ const path = document.createElementNS(SVG_NS, "path");
781
+ // Get SVG path data for freedraw using the provided utility
782
+ const pathData = getFreeDrawSvgPath(element);
783
+ path.setAttribute("d", pathData);
784
+ // Apply styles
785
+ // applyStyles(path, element.stroke, element.background);
786
+ applyStyles(path, [], element.stroke);
787
+ return path;
788
+ };
789
+ // Render iframe element
790
+ const renderIframe = (element) => {
791
+ // For SVG export, we represent iframes as rectangles with a label
792
+ const rect = document.createElementNS(SVG_NS, "rect");
793
+ rect.setAttribute("width", element.width.scoped.toString());
794
+ rect.setAttribute("height", element.height.scoped.toString());
795
+ // Apply styles
796
+ applyStyles(rect, element.stroke, element.background);
797
+ // Add a title to indicate it's an iframe
798
+ const title = document.createElementNS(SVG_NS, "title");
799
+ title.textContent = "Iframe: " + (element.link || "embedded content");
800
+ rect.appendChild(title);
801
+ return rect;
802
+ };
803
+ // Render table element
804
+ const renderTable = (element) => {
805
+ // For SVG export, we'll create a group with rectangles for cells
806
+ const tableGroup = document.createElementNS(SVG_NS, "g");
807
+ // TODO: We need to implement table rendering for SVG export
808
+ const { columnOrder, rowOrder, columns, rows, cells } = element;
809
+ // Create outer rectangle for table
810
+ const tableRect = document.createElementNS(SVG_NS, "rect");
811
+ tableRect.setAttribute("width", element.width.scoped.toString());
812
+ tableRect.setAttribute("height", element.height.scoped.toString());
813
+ applyStyles(tableRect, element.stroke, element.background);
814
+ tableGroup.appendChild(tableRect);
815
+ // Calculate cell dimensions
816
+ let currentY = 0;
817
+ // for (const rowId of rowOrder) {
818
+ // const row = rows[rowId];
819
+ // const rowHeight = row?.height?.scoped || (element.height.scoped / rowOrder.length);
820
+ // let currentX = 0;
821
+ // for (const colId of columnOrder) {
822
+ // const col = columns[colId];
823
+ // const colWidth = col?.width?.scoped || (element.width.scoped / columnOrder.length);
824
+ // // Create cell
825
+ // const cellKey = `${rowId}:${colId}`;
826
+ // const cell = cells[cellKey];
827
+ // if (cell) {
828
+ // // Create cell rectangle
829
+ // const cellRect = document.createElementNS(SVG_NS, "rect");
830
+ // cellRect.setAttribute("x", currentX.toString());
831
+ // cellRect.setAttribute("y", currentY.toString());
832
+ // cellRect.setAttribute("width", colWidth.toString());
833
+ // cellRect.setAttribute("height", rowHeight.toString());
834
+ // // Apply cell styles if available
835
+ // if (cell.style) {
836
+ // if (cell.style.background) {
837
+ // cellRect.setAttribute("fill", cell.style.background);
838
+ // }
839
+ // if (cell.style.border) {
840
+ // cellRect.setAttribute("stroke", cell.style.border.color || "#000");
841
+ // if (cell.style.border.width) {
842
+ // cellRect.setAttribute("stroke-width", cell.style.border.width.scoped.toString());
843
+ // }
844
+ // }
845
+ // }
846
+ // tableGroup.appendChild(cellRect);
847
+ // // Add cell text
848
+ // if (cell.data) {
849
+ // const cellText = document.createElementNS(SVG_NS, "text");
850
+ // cellText.setAttribute("x", (currentX + colWidth / 2).toString());
851
+ // cellText.setAttribute("y", (currentY + rowHeight / 2).toString());
852
+ // cellText.setAttribute("text-anchor", "middle");
853
+ // cellText.setAttribute("dominant-baseline", "central");
854
+ // if (cell.style?.text) {
855
+ // if (cell.style.text.color) {
856
+ // cellText.setAttribute("fill", cell.style.text.color);
857
+ // }
858
+ // if (cell.style.text.size) {
859
+ // cellText.setAttribute("font-size", cell.style.text.size.scoped.toString());
860
+ // }
861
+ // if (cell.style.text.font) {
862
+ // cellText.setAttribute("font-family", cell.style.text.font);
863
+ // }
864
+ // }
865
+ // cellText.textContent = cell.data;
866
+ // tableGroup.appendChild(cellText);
867
+ // }
868
+ // }
869
+ // currentX += colWidth;
870
+ // }
871
+ // currentY += rowHeight;
872
+ // }
873
+ return tableGroup;
874
+ };
875
+ // Render doc element
876
+ const renderDoc = (element) => {
877
+ // For SVG export, we'll create a foreign object with HTML content
878
+ const rect = document.createElementNS(SVG_NS, "rect");
879
+ // TODO: We need to implement doc rendering for SVG export
880
+ rect.setAttribute("width", element.width.scoped.toString());
881
+ rect.setAttribute("height", element.height.scoped.toString());
882
+ // Apply styles
883
+ applyStyles(rect, element.stroke, element.background);
884
+ return rect;
885
+ };