返回 presentation-ai
parser.ts
1 import { ColumnItemPlugin, ColumnPlugin } from "@platejs/layout/react";
2 import {
3 KEYS,
4 type Descendant,
5 type TColumnElement,
6 type TColumnGroupElement,
7 type TElement,
8 type TTableCellElement,
9 type TTableElement,
10 type TTableRowElement,
11 type TText,
12 } from "platejs";
13
14 import { type PresentationStockImageProvider } from "@/states/presentation-state";
15 import {
16 ANTV_INFOGRAPHIC,
17 AREA_CHART_ELEMENT,
18 BAR_CHART_ELEMENT,
19 BOX_PLOT_CHART_ELEMENT,
20 BUBBLE_CHART_ELEMENT,
21 CANDLESTICK_CHART_ELEMENT,
22 CHORD_CHART_ELEMENT,
23 CIRCULAR_GRID_GROUP,
24 CIRCULAR_GRID_ITEM,
25 COMPOSED_CHART_ELEMENT,
26 CONE_FUNNEL_CHART_ELEMENT,
27 CONNECTED_CIRCLES_GROUP,
28 CONNECTED_CIRCLES_ITEM,
29 CONTRIBUTOR_ELEMENT,
30 DONUT_CHART_ELEMENT,
31 FUNNEL_CHART_ELEMENT,
32 HEATMAP_CHART_ELEMENT,
33 HISTOGRAM_CHART_ELEMENT,
34 LABEL_ELEMENT,
35 LINE_CHART_ELEMENT,
36 LINEAR_GAUGE_ELEMENT,
37 NIGHTINGALE_CHART_ELEMENT,
38 OHLC_CHART_ELEMENT,
39 PIE_CHART_ELEMENT,
40 PRESENTATION_TITLE_ELEMENT,
41 PYRAMID_CHART_ELEMENT,
42 QUOTE_ELEMENT,
43 RADAR_CHART_ELEMENT,
44 RADIAL_BAR_CHART_ELEMENT,
45 RADIAL_COLUMN_CHART_ELEMENT,
46 RADIAL_GAUGE_ELEMENT,
47 RANGE_AREA_CHART_ELEMENT,
48 RANGE_BAR_CHART_ELEMENT,
49 SANKEY_CHART_ELEMENT,
50 SCATTER_CHART_ELEMENT,
51 SLOPE_GROUP,
52 SLOPE_ITEM,
53 SNAKE_GROUP,
54 SNAKE_ITEM,
55 SUNBURST_CHART_ELEMENT,
56 TREEMAP_CHART_ELEMENT,
57 WATERFALL_CHART_ELEMENT,
58 type TContributorElement,
59 type TLabelElement,
60 type TPresentationTitleElement,
61 } from "../editor/lib";
62 import { type TAntvInfographicElement } from "../editor/plugins/antv-infographic-plugin";
63 import {
64 type TArrowListElement,
65 type TArrowListItemElement,
66 } from "../editor/plugins/arrow-plugin";
67 import {
68 type TBeforeAfterGroupElement,
69 type TBeforeAfterSideElement,
70 } from "../editor/plugins/before-after-plugin";
71 import {
72 type TBoxGroupElement,
73 type TBoxItemElement,
74 } from "../editor/plugins/box-plugin";
75 import {
76 type TBulletGroupElement,
77 type TBulletItemElement,
78 } from "../editor/plugins/bullet-plugin";
79 import { type TButtonElement } from "../editor/plugins/button-plugin";
80 import {
81 type TCompareGroupElement,
82 type TCompareSideElement,
83 } from "../editor/plugins/compare-plugin";
84 import {
85 type TCycleGroupElement,
86 type TCycleItemElement,
87 } from "../editor/plugins/cycle-plugin";
88 import {
89 type TCircularGridGroupElement,
90 type TCircularGridItemElement,
91 type TConnectedCirclesGroupElement,
92 type TConnectedCirclesItemElement,
93 type TSlopeGroupElement,
94 type TSlopeItemElement,
95 type TSnakeGroupElement,
96 type TSnakeItemElement,
97 } from "../editor/plugins/diagram-components-plugin";
98 import {
99 type TIconListElement,
100 type TIconListItemElement,
101 } from "../editor/plugins/icon-list-plugin";
102 import { type TIconElement } from "../editor/plugins/icon-plugin";
103 import {
104 type TConsItemElement,
105 type TProsConsGroupElement,
106 type TProsItemElement,
107 } from "../editor/plugins/pros-cons-plugin";
108 import {
109 type TPyramidGroupElement,
110 type TPyramidItemElement,
111 } from "../editor/plugins/pyramid-plugin";
112 import { type TQuoteElement } from "../editor/plugins/quote-plugin";
113 import {
114 type TSequenceArrowGroupElement,
115 type TSequenceArrowItemElement,
116 } from "../editor/plugins/sequence-arrow-plugin";
117 import {
118 type TStairGroupElement,
119 type TStairItemElement,
120 } from "../editor/plugins/staircase-plugin";
121 import {
122 type TStatsGroupElement,
123 type TStatsItemElement,
124 } from "../editor/plugins/stats-plugin";
125 import {
126 type TStepsGroupElement,
127 type TStepsItemElement,
128 } from "../editor/plugins/steps-plugin";
129 import {
130 type TTimelineGroupElement,
131 type TTimelineItemElement,
132 } from "../editor/plugins/timeline-plugin";
133 import {
134 type GeneratingText,
135 type HeadingElement,
136 type ImageCropSettings,
137 type ImageElement,
138 type ParagraphElement,
139 type TChartElement,
140 } from "./types";
141
142 // Union type for all possible Plate elements
143 export type PlateNode =
144 | TElement
145 | ParagraphElement
146 | HeadingElement
147 | ImageElement
148 | TColumnElement
149 | TColumnGroupElement
150 | TBulletGroupElement
151 | TBulletItemElement
152 | TIconListItemElement
153 | TIconListElement
154 | TIconElement
155 | TCycleGroupElement
156 | TCycleItemElement
157 | TSlopeGroupElement
158 | TSlopeItemElement
159 | TConnectedCirclesGroupElement
160 | TConnectedCirclesItemElement
161 | TCircularGridGroupElement
162 | TCircularGridItemElement
163 | TSnakeGroupElement
164 | TSnakeItemElement
165 | TStairItemElement
166 | TStairGroupElement
167 | TPyramidGroupElement
168 | TPyramidItemElement
169 | TStepsGroupElement
170 | TStepsItemElement
171 | TArrowListElement
172 | TArrowListItemElement
173 | TTimelineGroupElement
174 | TTimelineItemElement
175 | TChartElement
176 | TBoxGroupElement
177 | TBoxItemElement
178 | TCompareGroupElement
179 | TCompareSideElement
180 | TBeforeAfterGroupElement
181 | TBeforeAfterSideElement
182 | TProsConsGroupElement
183 | TProsItemElement
184 | TConsItemElement
185 | TSequenceArrowGroupElement
186 | TSequenceArrowItemElement
187 | TButtonElement
188 | TContributorElement
189 | TLabelElement
190 | TPresentationTitleElement
191 | TTableElement
192 | TTableRowElement
193 | TTableCellElement
194 | TQuoteElement
195 | TAntvInfographicElement;
196
197 export type LayoutType = "left" | "right" | "vertical" | "background" | "none";
198 export type RootImage = {
199 query: string;
200 url?: string;
201 embedType?: string;
202 imageSource?: "generate" | "search" | "gif" | "upload";
203 stockImageProvider?: PresentationStockImageProvider;
204 cropSettings?: ImageCropSettings;
205 layoutType?: LayoutType;
206 size?: { w?: string; h?: number };
207 isQueryStreaming?: boolean;
208 // Chart support
209 chartType?: string;
210 chartData?: unknown;
211 chartOptions?: Record<string, unknown>;
212 paletteDropMutable?: boolean;
213 };
214
215 export type PlateSlide = {
216 id: string;
217 content: PlateNode[];
218 rootImage?: RootImage;
219 layoutType?: LayoutType | undefined;
220 alignment?: "start" | "center" | "end";
221 bgColor?: string;
222 width?: "S" | "M" | "L";
223 fontSize?: "S" | "M" | "L";
224 fontFamily?: {
225 heading: string;
226 body: string;
227 headingUrl?: string;
228 bodyUrl?: string;
229 headingWeight?: number;
230 bodyWeight?: number;
231 };
232 formatCategory?: "presentation" | "social" | "document" | "webpage";
233 aspectRatio?: {
234 type: "fluid" | "ratio" | "tall" | "preset";
235 value?: string;
236 };
237 isImageSlide?: boolean;
238 };
239
240 // Updated XMLNode to support mixed content (text and elements interleaved)
241 interface XMLNode {
242 tag: string;
243 attributes: Record<string, string>;
244 children: Array<XMLNode | XMLTextNode>;
245 originalTagContent?: string;
246 }
247
248 interface XMLTextNode {
249 text: string;
250 }
251
252 function isTextNode(node: XMLNode | XMLTextNode): node is XMLTextNode {
253 return "text" in node && !("tag" in node);
254 }
255
256 function isElementNode(node: XMLNode | XMLTextNode): node is XMLNode {
257 return "tag" in node;
258 }
259
260 function hashStableText(input: string): string {
261 let hash = 0;
262
263 for (let index = 0; index < input.length; index += 1) {
264 hash = (hash << 5) - hash + input.charCodeAt(index);
265 hash |= 0;
266 }
267
268 return Math.abs(hash).toString(36);
269 }
270
271 const DETERMINISTIC_ID_ALPHABET =
272 "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
273
274 function createDeterministicRandomId(seed: string, length = 21): string {
275 let state = 0x811c9dc5;
276
277 for (let index = 0; index < seed.length; index += 1) {
278 state ^= seed.charCodeAt(index);
279 state = Math.imul(state, 0x01000193);
280 }
281
282 let id = "";
283 for (let index = 0; index < length; index += 1) {
284 state += 0x6d2b79f5;
285 let next = state;
286 next = Math.imul(next ^ (next >>> 15), next | 1);
287 next ^= next + Math.imul(next ^ (next >>> 7), next | 61);
288 const randomValue = ((next ^ (next >>> 14)) >>> 0) / 4294967296;
289 id +=
290 DETERMINISTIC_ID_ALPHABET[
291 Math.floor(randomValue * DETERMINISTIC_ID_ALPHABET.length)
292 ];
293 }
294
295 return id;
296 }
297
298 function unescapeXmlText(text: string): string {
299 return text
300 .replace(/&quot;/g, '"')
301 .replace(/&apos;/g, "'")
302 .replace(/&gt;/g, ">")
303 .replace(/&lt;/g, "<")
304 .replace(/&amp;/g, "&");
305 }
306
307 const CHART_XML_TYPE_TO_ELEMENT: Record<string, string> = {
308 pie: PIE_CHART_ELEMENT,
309 bar: BAR_CHART_ELEMENT,
310 area: AREA_CHART_ELEMENT,
311 radar: RADAR_CHART_ELEMENT,
312 scatter: SCATTER_CHART_ELEMENT,
313 line: LINE_CHART_ELEMENT,
314 "radial-bar": RADIAL_BAR_CHART_ELEMENT,
315 composed: COMPOSED_CHART_ELEMENT,
316 treemap: TREEMAP_CHART_ELEMENT,
317 bubble: BUBBLE_CHART_ELEMENT,
318 donut: DONUT_CHART_ELEMENT,
319 histogram: HISTOGRAM_CHART_ELEMENT,
320 heatmap: HEATMAP_CHART_ELEMENT,
321 "range-bar": RANGE_BAR_CHART_ELEMENT,
322 "range-area": RANGE_AREA_CHART_ELEMENT,
323 waterfall: WATERFALL_CHART_ELEMENT,
324 "box-plot": BOX_PLOT_CHART_ELEMENT,
325 boxplot: BOX_PLOT_CHART_ELEMENT,
326 candlestick: CANDLESTICK_CHART_ELEMENT,
327 ohlc: OHLC_CHART_ELEMENT,
328 nightingale: NIGHTINGALE_CHART_ELEMENT,
329 "radial-column": RADIAL_COLUMN_CHART_ELEMENT,
330 sunburst: SUNBURST_CHART_ELEMENT,
331 sankey: SANKEY_CHART_ELEMENT,
332 chord: CHORD_CHART_ELEMENT,
333 funnel: FUNNEL_CHART_ELEMENT,
334 "cone-funnel": CONE_FUNNEL_CHART_ELEMENT,
335 pyramid: PYRAMID_CHART_ELEMENT,
336 "radial-gauge": RADIAL_GAUGE_ELEMENT,
337 "linear-gauge": LINEAR_GAUGE_ELEMENT,
338 };
339
340 function getChartElementType(chartType: string): string {
341 const normalizedChartType = chartType.trim().toLowerCase();
342 return (
343 CHART_XML_TYPE_TO_ELEMENT[normalizedChartType] ??
344 (normalizedChartType.startsWith("chart-")
345 ? normalizedChartType
346 : BAR_CHART_ELEMENT)
347 );
348 }
349
350 function isRecord(value: unknown): value is Record<string, unknown> {
351 return typeof value === "object" && value !== null && !Array.isArray(value);
352 }
353
354 function isElementRecord(value: unknown): value is TElement {
355 return (
356 isRecord(value) &&
357 typeof value.type === "string" &&
358 Array.isArray(value.children)
359 );
360 }
361
362 function parseJsonPayload(text: string): unknown | undefined {
363 const trimmedText = unescapeXmlText(text).trim();
364 if (!trimmedText) return undefined;
365
366 try {
367 return JSON.parse(trimmedText) as unknown;
368 } catch {
369 return undefined;
370 }
371 }
372
373 function parsePrimitiveXmlValue(value: string): string | number {
374 const trimmedValue = value.trim();
375 const numericText = trimmedValue.endsWith("%")
376 ? trimmedValue.slice(0, -1)
377 : trimmedValue;
378 const numericValue = Number(numericText);
379
380 return numericText !== "" && Number.isFinite(numericValue)
381 ? numericValue
382 : trimmedValue;
383 }
384
385 function splitMarkdownTableRow(line: string): string[] {
386 const trimmedLine = line.trim().replace(/^\|/, "").replace(/\|$/, "");
387 const cells: string[] = [];
388 let currentCell = "";
389 let isEscaped = false;
390
391 for (const character of trimmedLine) {
392 if (isEscaped) {
393 currentCell += character;
394 isEscaped = false;
395 continue;
396 }
397
398 if (character === "\\") {
399 isEscaped = true;
400 continue;
401 }
402
403 if (character === "|") {
404 cells.push(currentCell.trim());
405 currentCell = "";
406 continue;
407 }
408
409 currentCell += character;
410 }
411
412 cells.push(currentCell.trim());
413
414 return cells;
415 }
416
417 function isMarkdownSeparatorRow(line: string): boolean {
418 const cells = splitMarkdownTableRow(line);
419
420 return (
421 cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()))
422 );
423 }
424
425 function parseMarkdownTableRows(
426 text: string,
427 ): Array<Record<string, string | number>> {
428 const lines = unescapeXmlText(text)
429 .split(/\r?\n/)
430 .map((line) => line.trim())
431 .filter(Boolean);
432 const separatorIndex = lines.findIndex(isMarkdownSeparatorRow);
433
434 if (separatorIndex <= 0) {
435 return [];
436 }
437
438 const headerLine = lines[separatorIndex - 1];
439 if (!headerLine) {
440 return [];
441 }
442
443 const headers = splitMarkdownTableRow(headerLine).filter(Boolean);
444 if (headers.length === 0) {
445 return [];
446 }
447
448 return lines.slice(separatorIndex + 1).flatMap((line) => {
449 if (!line.includes("|") || line.startsWith("<")) {
450 return [];
451 }
452
453 const cells = splitMarkdownTableRow(line);
454 if (cells.length === 0) {
455 return [];
456 }
457
458 const row: Record<string, string | number> = {};
459 headers.forEach((header, index) => {
460 const cell = cells[index];
461 if (cell !== undefined && cell !== "") {
462 row[header] = parsePrimitiveXmlValue(cell);
463 }
464 });
465
466 return Object.keys(row).length > 0 ? [row] : [];
467 });
468 }
469
470 function parseBooleanAttribute(value: string | undefined): boolean | undefined {
471 if (value === undefined) return undefined;
472 if (value === "true" || value === "1") return true;
473 if (value === "false" || value === "0") return false;
474 return undefined;
475 }
476
477 function parseOrientationAttribute(
478 value: string | undefined,
479 ): "vertical" | "horizontal" | undefined {
480 if (value === "vertical" || value === "horizontal") return value;
481 return undefined;
482 }
483
484 function parseSidednessAttribute(
485 value: string | undefined,
486 ): "single" | "double" | undefined {
487 if (value === "single" || value === "double") return value;
488 return undefined;
489 }
490
491 function parseAlignmentAttribute(
492 value: string | undefined,
493 ): "left" | "center" | "right" | undefined {
494 if (value === "left" || value === "center" || value === "right") {
495 return value;
496 }
497
498 return undefined;
499 }
500
501 function parsePresentationTitleVariant(
502 value: string | undefined,
503 ): TPresentationTitleElement["variant"] {
504 if (value === "display" || value === "humongous" || value === "title") {
505 return value;
506 }
507
508 return "title";
509 }
510
511 function isAntvInfographicSyntax(text: string): boolean {
512 const lines = text
513 .trim()
514 .split(/\r?\n/)
515 .map((line) => line.trim())
516 .filter(Boolean);
517
518 if (lines.length < 2) {
519 return false;
520 }
521
522 return /^infographic\s+\S+/i.test(lines[0] ?? "") && lines[1] === "theme";
523 }
524
525 function extractLegacyInfographicFencePrompts(text: string): string[] {
526 const prompts: string[] = [];
527 const fencePattern = /```[ \t]*infographic[^\n]*(?:\n)?([\s\S]*?)(```|$)/gi;
528 let match = fencePattern.exec(text);
529
530 while (match !== null) {
531 const prompt = unescapeXmlText(match[1] ?? "").trim();
532
533 if (!prompt || match[2] !== "```") {
534 match = fencePattern.exec(text);
535 continue;
536 }
537
538 prompts.push(prompt);
539
540 match = fencePattern.exec(text);
541 }
542
543 return prompts;
544 }
545
546 /**
547 * Class to parse XML presentation data into Plate.js format with improved streaming support
548 */
549 export class SlideParser {
550 private buffer = "";
551 private completedSections: string[] = [];
552 private parsedSlides: PlateSlide[] = [];
553 private lastInputLength = 0;
554 private sectionIdMap = new Map<string, string>();
555 private latestContent = "";
556 private sectionCounter = 0;
557
558 /**
559 * Parse a chunk of XML data
560 */
561 public parseChunk(chunk: string): PlateSlide[] {
562 this.latestContent = chunk;
563
564 const isFullContent =
565 chunk.length >= this.lastInputLength &&
566 chunk.substring(0, this.lastInputLength) ===
567 this.buffer.substring(0, this.lastInputLength);
568
569 if (isFullContent && this.lastInputLength > 0) {
570 this.buffer = this.buffer + chunk.substring(this.lastInputLength);
571 } else {
572 this.buffer = chunk;
573 }
574
575 this.lastInputLength = chunk.length;
576 this.extractCompleteSections();
577 const newSlides = this.processSections();
578
579 return newSlides;
580 }
581
582 /**
583 * Finalize parsing with any remaining content
584 */
585 public finalize(): PlateSlide[] {
586 try {
587 this.extractCompleteSections();
588
589 let remainingBuffer = this.buffer.trim();
590
591 if (remainingBuffer.startsWith("<PRESENTATION")) {
592 const tagEndIdx = remainingBuffer.indexOf(">");
593 if (tagEndIdx !== -1) {
594 remainingBuffer = remainingBuffer.substring(tagEndIdx + 1).trim();
595 }
596 }
597
598 if (remainingBuffer.startsWith("<SECTION")) {
599 const fixedSection = remainingBuffer + "</SECTION>";
600 this.completedSections.push(fixedSection);
601 }
602
603 const finalSlides = this.processSections();
604 this.latestContent = "";
605
606 return finalSlides;
607 } catch (e) {
608 console.error("Error during finalization:", e);
609 return [];
610 }
611 }
612
613 /**
614 * Get all parsed slides
615 */
616 public getAllSlides(): PlateSlide[] {
617 return this.parsedSlides;
618 }
619
620 /**
621 * Reset the parser state
622 */
623 public reset(): void {
624 this.buffer = "";
625 this.completedSections = [];
626 this.parsedSlides = [];
627 this.lastInputLength = 0;
628 this.latestContent = "";
629 this.sectionCounter = 0;
630 }
631
632 /**
633 * Manually clear all generating marks from all slides
634 */
635 public clearAllGeneratingMarks(): void {
636 for (const slide of this.parsedSlides) {
637 this.clearGeneratingMarksFromNodes(slide.content as Descendant[]);
638 }
639 this.latestContent = "";
640 }
641
642 /**
643 * Clear all generating marks from a tree of nodes
644 */
645 private clearGeneratingMarksFromNodes(nodes: Descendant[]): void {
646 for (const node of nodes) {
647 if ("text" in node && (node as GeneratingText).generating !== undefined) {
648 (node as GeneratingText).generating = undefined;
649 }
650
651 if (
652 "children" in node &&
653 Array.isArray(node.children) &&
654 node.children.length > 0
655 ) {
656 this.clearGeneratingMarksFromNodes(node.children as Descendant[]);
657 }
658 }
659 }
660
661 /**
662 * Process the completed sections into Plate slides
663 */
664 private processSections(): PlateSlide[] {
665 if (this.completedSections.length === 0) {
666 return [];
667 }
668
669 const newSlides = this.completedSections.map(this.convertSectionToPlate);
670 this.parsedSlides = [...this.parsedSlides, ...newSlides];
671 this.completedSections = [];
672
673 return newSlides;
674 }
675
676 /**
677 * Extract SECTION blocks from the buffer
678 */
679 private extractCompleteSections(): void {
680 let startIdx = 0;
681 let extractedSectionEndIdx = 0;
682
683 const presentationStartIdx = this.buffer.indexOf("<PRESENTATION");
684 if (presentationStartIdx !== -1 && presentationStartIdx < 10) {
685 const tagEndIdx = this.buffer.indexOf(">", presentationStartIdx);
686 if (tagEndIdx !== -1) {
687 startIdx = tagEndIdx + 1;
688
689 const commentStartIdx = this.buffer.indexOf("<!--", startIdx);
690 if (commentStartIdx !== -1 && commentStartIdx < startIdx + 20) {
691 const commentEndIdx = this.buffer.indexOf("-->", commentStartIdx);
692 if (commentEndIdx !== -1) {
693 startIdx = commentEndIdx + 3;
694 }
695 }
696 }
697 }
698
699 while (true) {
700 const sectionStartIdx = this.buffer.indexOf("<SECTION", startIdx);
701 if (sectionStartIdx === -1) break;
702
703 const sectionEndIdx = this.buffer.indexOf("</SECTION>", sectionStartIdx);
704 const nextSectionIdx = this.buffer.indexOf(
705 "<SECTION",
706 sectionStartIdx + 1,
707 );
708
709 if (
710 sectionEndIdx !== -1 &&
711 (nextSectionIdx === -1 || sectionEndIdx < nextSectionIdx)
712 ) {
713 const completeSection = this.buffer.substring(
714 sectionStartIdx,
715 sectionEndIdx + "</SECTION>".length,
716 );
717
718 this.completedSections.push(completeSection);
719 startIdx = sectionEndIdx + "</SECTION>".length;
720 extractedSectionEndIdx = startIdx;
721 } else if (nextSectionIdx !== -1) {
722 const partialSection = this.buffer.substring(
723 sectionStartIdx,
724 nextSectionIdx,
725 );
726
727 if (
728 partialSection.includes("<H1>") ||
729 partialSection.includes("<H2>") ||
730 partialSection.includes("<H3>") ||
731 partialSection.includes("<PYRAMID>") ||
732 partialSection.includes("<ARROWS>") ||
733 partialSection.includes("<TIMELINE>") ||
734 partialSection.includes("<P>") ||
735 partialSection.includes("<ICON") ||
736 partialSection.includes("<IMG") ||
737 partialSection.includes("<INFOGRAPHIC")
738 ) {
739 this.completedSections.push(partialSection + "</SECTION>");
740 }
741
742 startIdx = nextSectionIdx;
743 extractedSectionEndIdx = nextSectionIdx;
744 } else {
745 break;
746 }
747 }
748
749 if (extractedSectionEndIdx > 0) {
750 this.buffer = this.buffer.substring(extractedSectionEndIdx);
751 }
752 }
753
754 /**
755 * Generate a section identifier
756 */
757 private generateSectionIdentifier(sectionNode: XMLNode): string {
758 // Position prefix ensures unique fingerprints for slides at different positions,
759 // while maintaining stable IDs across re-parses during streaming
760 const positionPrefix = `pos-${this.sectionCounter++}-`;
761
762 const h1Node = sectionNode.children.find(
763 (child) => isElementNode(child) && child.tag.toUpperCase() === "H1",
764 ) as XMLNode | undefined;
765
766 if (h1Node) {
767 const headingContent = this.getTextContent(h1Node);
768 if (headingContent.trim().length > 0) {
769 return `${positionPrefix}heading-${headingContent.trim()}`;
770 }
771 }
772
773 let fingerprint = "";
774
775 const attrKeys = Object.keys(sectionNode.attributes).sort();
776 if (attrKeys.length > 0) {
777 fingerprint += attrKeys
778 .map((key) => `${key}=${sectionNode.attributes[key]}`)
779 .join(";");
780 }
781
782 const childTags = sectionNode.children
783 .filter(isElementNode)
784 .slice(0, 3)
785 .map((child) => child.tag.toUpperCase());
786 if (childTags.length > 0) {
787 fingerprint += "|" + childTags.join("-");
788 }
789
790 if (fingerprint.length < 5) {
791 let hash = 0;
792 const fullContent = sectionNode.originalTagContent ?? "";
793 for (let i = 0; i < fullContent.length; i++) {
794 const char = fullContent.charCodeAt(i);
795 hash = (hash << 5) - hash + char;
796 hash = hash & hash;
797 }
798 fingerprint = `content-hash-${Math.abs(hash)}`;
799 }
800
801 return `${positionPrefix}${fingerprint}`;
802 }
803
804 /**
805 * Convert an XML section string to Plate.js format
806 */
807 private convertSectionToPlate = (sectionString: string): PlateSlide => {
808 const rootNode = this.parseXML(sectionString);
809
810 const sectionNode = rootNode.children.find(
811 (child) => isElementNode(child) && child.tag.toUpperCase() === "SECTION",
812 ) as XMLNode | undefined;
813
814 if (!sectionNode) {
815 return {
816 id: createDeterministicRandomId(`slide:${sectionString || "empty"}`),
817 content: [],
818 layoutType: undefined,
819 alignment: "center",
820 };
821 }
822
823 let slideId: string;
824 if (sectionNode.attributes.id) {
825 slideId = sectionNode.attributes.id;
826 } else {
827 const sectionIdentifier = this.generateSectionIdentifier(sectionNode);
828
829 if (this.sectionIdMap.has(sectionIdentifier)) {
830 slideId = this.sectionIdMap.get(sectionIdentifier)!;
831 } else {
832 slideId = createDeterministicRandomId(`slide:${sectionIdentifier}`);
833 this.sectionIdMap.set(sectionIdentifier, slideId);
834 }
835 }
836
837 let layoutType: LayoutType | undefined;
838 const layoutAttr = sectionNode.attributes.layout;
839
840 if (layoutAttr) {
841 if (
842 layoutAttr === "left" ||
843 layoutAttr === "right" ||
844 layoutAttr === "vertical" ||
845 layoutAttr === "background"
846 ) {
847 layoutType = layoutAttr as LayoutType;
848 } else {
849 layoutType = "left";
850 }
851 }
852
853 // Check for isImageSlide attribute
854 const isImageSlideAttr = sectionNode.attributes.isImageSlide;
855 const isImageSlide =
856 isImageSlideAttr === "true" || isImageSlideAttr === "1";
857
858 const plateElements: PlateNode[] = [];
859 let rootImage: RootImage | undefined;
860
861 for (const child of sectionNode.children) {
862 if (isTextNode(child)) {
863 rootImage ??= this.createRootImageFromTagContent(
864 child.text,
865 layoutType,
866 );
867
868 const infographicElements = this.createLegacyInfographicsFromText(
869 child.text,
870 slideId,
871 layoutType,
872 );
873 plateElements.push(...infographicElements);
874 continue;
875 }
876
877 if (!isElementNode(child)) continue;
878
879 if (child.tag.toUpperCase() === "IMG") {
880 rootImage ??= this.parseRootImageFromNode(child, layoutType);
881 if (!rootImage && child.originalTagContent) {
882 rootImage = this.createRootImageFromTagContent(
883 child.originalTagContent,
884 layoutType,
885 );
886 }
887 continue;
888 }
889
890 if (child.tag.toUpperCase() === "DIV") {
891 for (const divChild of child.children) {
892 if (!isElementNode(divChild)) continue;
893 const processedElement = this.processTopLevelNode(
894 divChild,
895 slideId,
896 layoutType,
897 );
898 if (processedElement) {
899 plateElements.push(processedElement);
900 }
901 }
902 } else {
903 const processedElement = this.processTopLevelNode(
904 child,
905 slideId,
906 layoutType,
907 );
908 if (processedElement) {
909 plateElements.push(processedElement);
910 }
911 }
912 }
913
914 return {
915 id: slideId,
916 content: this.withStableElementIds(plateElements, slideId),
917 ...(rootImage ? { rootImage } : {}),
918 ...(layoutType ? { layoutType: layoutType } : {}),
919 ...(isImageSlide ? { isImageSlide: true } : {}),
920 alignment: "center",
921 };
922 };
923
924 private createStableElementId(
925 slideId: string,
926 path: readonly number[],
927 type: string,
928 ): string {
929 return createDeterministicRandomId(
930 `element:${slideId}:${path.join(".")}:${type}`,
931 );
932 }
933
934 private withStableElementIds<TNode extends PlateNode>(
935 nodes: TNode[],
936 slideId: string,
937 ): TNode[] {
938 const addIds = (node: Descendant, path: number[]): Descendant => {
939 if (!isElementRecord(node)) {
940 return node;
941 }
942
943 const nextChildren = node.children.map((child, childIndex) =>
944 addIds(child, [...path, childIndex]),
945 );
946 const existingId =
947 isRecord(node) && typeof node.id === "string" ? node.id.trim() : "";
948
949 return {
950 ...node,
951 id: existingId || this.createStableElementId(slideId, path, node.type),
952 children: nextChildren,
953 } as Descendant;
954 };
955
956 return nodes.map((node, index) => addIds(node, [index]) as TNode);
957 }
958
959 private extractAttributeFromTagContent(
960 tagContent: string,
961 attributeName: string,
962 ): { value: string; isComplete: boolean } | null {
963 const attributeStart = tagContent.indexOf(`${attributeName}=`);
964 if (attributeStart === -1) {
965 return null;
966 }
967
968 const afterAttribute = tagContent.substring(
969 attributeStart + attributeName.length + 1,
970 );
971 if (afterAttribute.length === 0) {
972 return null;
973 }
974
975 const quoteChar = afterAttribute[0];
976 if (quoteChar !== '"' && quoteChar !== "'") {
977 const nextSpaceIndex = afterAttribute.search(/\s/);
978 const value =
979 nextSpaceIndex === -1
980 ? afterAttribute
981 : afterAttribute.substring(0, nextSpaceIndex);
982 return {
983 value,
984 isComplete: nextSpaceIndex !== -1 || tagContent.includes(">"),
985 };
986 }
987
988 const closingQuoteIdx = afterAttribute.indexOf(quoteChar, 1);
989 if (closingQuoteIdx !== -1) {
990 return {
991 value: afterAttribute.substring(1, closingQuoteIdx),
992 isComplete: true,
993 };
994 }
995
996 const rawValue = afterAttribute.substring(1);
997 const nextTagIndex = rawValue.indexOf("<");
998 return {
999 value:
1000 nextTagIndex === -1 ? rawValue : rawValue.substring(0, nextTagIndex),
1001 isComplete: false,
1002 };
1003 }
1004
1005 private createRootImageFromTagContent(
1006 tagContent: string,
1007 layoutType: LayoutType | undefined,
1008 ): RootImage | undefined {
1009 if (!tagContent.includes("<IMG")) {
1010 return undefined;
1011 }
1012
1013 const urlAttribute =
1014 this.extractAttributeFromTagContent(tagContent, "url") ??
1015 this.extractAttributeFromTagContent(tagContent, "src");
1016 const completeUrl =
1017 urlAttribute?.isComplete && urlAttribute.value.trim().length > 0
1018 ? urlAttribute.value
1019 : "";
1020
1021 const queryAttribute = this.extractAttributeFromTagContent(
1022 tagContent,
1023 "query",
1024 );
1025 const query = queryAttribute?.value.trim() ?? "";
1026 const imageSourceAttribute = this.extractAttributeFromTagContent(
1027 tagContent,
1028 "imageSource",
1029 );
1030 const stockImageProviderAttribute = this.extractAttributeFromTagContent(
1031 tagContent,
1032 "stockImageProvider",
1033 );
1034
1035 if (query.length > 0) {
1036 return {
1037 query: queryAttribute?.value ?? "",
1038 layoutType,
1039 ...(completeUrl ? { url: completeUrl } : {}),
1040 ...(completeUrl ? { imageSource: "search" as const } : {}),
1041 ...(imageSourceAttribute?.isComplete
1042 ? {
1043 imageSource:
1044 imageSourceAttribute.value as RootImage["imageSource"],
1045 }
1046 : {}),
1047 ...(stockImageProviderAttribute?.isComplete
1048 ? {
1049 stockImageProvider:
1050 stockImageProviderAttribute.value as PresentationStockImageProvider,
1051 }
1052 : {}),
1053 ...(queryAttribute?.isComplete ? {} : { isQueryStreaming: true }),
1054 };
1055 }
1056
1057 if (completeUrl) {
1058 return {
1059 query: "",
1060 layoutType,
1061 url: completeUrl,
1062 imageSource: "search",
1063 };
1064 }
1065
1066 return undefined;
1067 }
1068
1069 private getFirstChildByTag(
1070 node: XMLNode,
1071 tagName: string,
1072 ): XMLNode | undefined {
1073 const normalizedTagName = tagName.toUpperCase();
1074 return node.children.find(
1075 (child) =>
1076 isElementNode(child) && child.tag.toUpperCase() === normalizedTagName,
1077 ) as XMLNode | undefined;
1078 }
1079
1080 private parseJsonChild(node: XMLNode, tagName: string): unknown | undefined {
1081 const child = this.getFirstChildByTag(node, tagName);
1082 if (!child) return undefined;
1083
1084 return parseJsonPayload(this.getTextContent(child));
1085 }
1086
1087 private parseChartDataRows(node: XMLNode): unknown[] {
1088 return parseMarkdownTableRows(this.getDirectTextContent(node));
1089 }
1090
1091 private getOptionsFromAttributes(
1092 node: XMLNode,
1093 excludedKeys: readonly string[],
1094 ): Record<string, unknown> {
1095 const excludedKeySet = new Set(excludedKeys);
1096 const options: Record<string, unknown> = {};
1097
1098 for (const [key, value] of Object.entries(node.attributes)) {
1099 if (!excludedKeySet.has(key)) {
1100 options[key] = value;
1101 }
1102 }
1103
1104 return options;
1105 }
1106
1107 private parseRootImageFromNode(
1108 node: XMLNode,
1109 layoutType: LayoutType | undefined,
1110 ): RootImage | undefined {
1111 const query = node.attributes.query ?? "";
1112 const url = node.attributes.url ?? node.attributes.src ?? "";
1113 const chartType = node.attributes.charttype
1114 ? getChartElementType(node.attributes.charttype)
1115 : undefined;
1116 const chartData = this.parseChartDataRows(node);
1117 const chartOptions = this.parseJsonChild(node, "OPTIONS");
1118 const cropSettings = this.parseJsonChild(node, "CROP");
1119 const width = node.attributes.width;
1120 const height = node.attributes.height
1121 ? Number.parseFloat(node.attributes.height)
1122 : undefined;
1123 const imageLayoutType =
1124 (node.attributes.layoutType as LayoutType | undefined) ?? layoutType;
1125
1126 if (!query && !url && !chartType) {
1127 return undefined;
1128 }
1129
1130 return {
1131 query,
1132 ...(imageLayoutType ? { layoutType: imageLayoutType } : {}),
1133 ...(url ? { url, imageSource: "search" as const } : {}),
1134 ...(node.attributes.embedType
1135 ? { embedType: node.attributes.embedType }
1136 : {}),
1137 ...(node.attributes.imageSource
1138 ? {
1139 imageSource: node.attributes
1140 .imageSource as RootImage["imageSource"],
1141 }
1142 : {}),
1143 ...(node.attributes.stockImageProvider
1144 ? {
1145 stockImageProvider: node.attributes
1146 .stockImageProvider as PresentationStockImageProvider,
1147 }
1148 : {}),
1149 ...(width || height !== undefined
1150 ? {
1151 size: {
1152 ...(width ? { w: width } : {}),
1153 ...(height !== undefined ? { h: height } : {}),
1154 },
1155 }
1156 : {}),
1157 ...(isRecord(cropSettings)
1158 ? { cropSettings: cropSettings as unknown as RootImage["cropSettings"] }
1159 : {}),
1160 ...(chartType ? { chartType } : {}),
1161 ...(chartType ? { chartData } : {}),
1162 ...(isRecord(chartOptions)
1163 ? { chartOptions }
1164 : chartType
1165 ? {
1166 chartOptions: this.getOptionsFromAttributes(node, [
1167 "query",
1168 "url",
1169 "src",
1170 "layoutType",
1171 "width",
1172 "height",
1173 "charttype",
1174 ]),
1175 }
1176 : {}),
1177 ...(node.attributes.paletteDropMutable !== undefined
1178 ? {
1179 paletteDropMutable:
1180 parseBooleanAttribute(node.attributes.paletteDropMutable) ??
1181 false,
1182 }
1183 : {}),
1184 };
1185 }
1186
1187 private createInfographicFromPrompt(
1188 prompt: string,
1189 idSeed: string,
1190 layoutType?: LayoutType,
1191 ): TAntvInfographicElement | null {
1192 const trimmedPrompt = prompt.trim();
1193
1194 if (!trimmedPrompt) {
1195 return null;
1196 }
1197
1198 const isGeneratedSyntax = isAntvInfographicSyntax(trimmedPrompt);
1199 const stableKey = hashStableText(`${idSeed}:${trimmedPrompt}`);
1200
1201 return {
1202 type: ANTV_INFOGRAPHIC,
1203 id: `infographic-${stableKey}`,
1204 generationPrompt: trimmedPrompt,
1205 ...(layoutType ? { slideLayoutType: layoutType } : {}),
1206 isLoading: !isGeneratedSyntax,
1207 syntax: isGeneratedSyntax ? trimmedPrompt : "",
1208 children: [{ text: "" } as TText],
1209 };
1210 }
1211
1212 private createInfographic(
1213 node: XMLNode,
1214 slideId: string,
1215 layoutType?: LayoutType,
1216 ): PlateNode | null {
1217 const prompt =
1218 node.attributes.prompt ??
1219 node.attributes.query ??
1220 this.getTextContent(this.getFirstChildByTag(node, "PROMPT") ?? node);
1221 const syntax = this.getFirstChildByTag(node, "SYNTAX");
1222 const source = this.getFirstChildByTag(node, "SOURCE");
1223 const data = this.parseJsonChild(node, "DATA");
1224 const syntaxText = syntax ? this.getTextContent(syntax).trim() : "";
1225 const promptText = unescapeXmlText(prompt).trim();
1226 const content = syntaxText || promptText;
1227 const stableKey = hashStableText(
1228 `${slideId}:${node.originalTagContent ?? ""}:${content}`,
1229 );
1230
1231 if (!content) {
1232 return null;
1233 }
1234
1235 const parsedIsLoading = parseBooleanAttribute(node.attributes.isLoading);
1236 const isGeneratedSyntax = syntaxText
1237 ? isAntvInfographicSyntax(syntaxText)
1238 : isAntvInfographicSyntax(promptText);
1239 const width = node.attributes.width
1240 ? Number.parseFloat(node.attributes.width)
1241 : undefined;
1242
1243 return {
1244 type: ANTV_INFOGRAPHIC,
1245 id: node.attributes.id ?? `infographic-${stableKey}`,
1246 generationPrompt: promptText || content,
1247 ...(source ? { sourceText: this.getTextContent(source).trim() } : {}),
1248 ...(node.attributes.slideLayoutType
1249 ? { slideLayoutType: node.attributes.slideLayoutType as LayoutType }
1250 : layoutType
1251 ? { slideLayoutType: layoutType }
1252 : {}),
1253 ...(width !== undefined && !Number.isNaN(width)
1254 ? { width }
1255 : node.attributes.width
1256 ? { width: node.attributes.width }
1257 : {}),
1258 ...(node.attributes.align === "left" ||
1259 node.attributes.align === "center" ||
1260 node.attributes.align === "right"
1261 ? { align: node.attributes.align }
1262 : {}),
1263 ...(isRecord(data)
1264 ? { data: data as TAntvInfographicElement["data"] }
1265 : {}),
1266 isLoading: parsedIsLoading ?? !isGeneratedSyntax,
1267 syntax: syntaxText || (isGeneratedSyntax ? promptText : ""),
1268 children: [{ text: "" } as TText],
1269 } as TAntvInfographicElement;
1270 }
1271
1272 private createLegacyInfographicsFromText(
1273 text: string,
1274 slideId: string,
1275 layoutType?: LayoutType,
1276 ): TAntvInfographicElement[] {
1277 return extractLegacyInfographicFencePrompts(text).flatMap(
1278 (prompt, index) => {
1279 const infographic = this.createInfographicFromPrompt(
1280 prompt,
1281 `${slideId}:legacy:${index}`,
1282 layoutType,
1283 );
1284
1285 return infographic ? [infographic] : [];
1286 },
1287 );
1288 }
1289
1290 /**
1291 * Process a top-level node in the SECTION
1292 */
1293 private processTopLevelNode(
1294 node: XMLNode,
1295 slideId: string,
1296 layoutType?: LayoutType,
1297 ): PlateNode | null {
1298 const tag = node.tag.toUpperCase();
1299
1300 switch (tag) {
1301 case "H1":
1302 case "H2":
1303 case "H3":
1304 case "H4":
1305 case "H5":
1306 case "H6":
1307 return this.createHeading(
1308 tag.toLowerCase() as "h1" | "h2" | "h3" | "h4" | "h5" | "h6",
1309 node,
1310 );
1311 case "TITLE":
1312 case "PRESENTATION-TITLE":
1313 case "PRESENTATION_TITLE":
1314 return this.createPresentationTitle(node);
1315 case "LABEL":
1316 return this.createLabel(node);
1317 case "CONTRIBUTOR":
1318 return this.createContributor(node);
1319 case "BLOCKQUOTE":
1320 return this.createBlockquote(node);
1321 case "CALLOUT":
1322 return this.createCallout(node);
1323 case "CODE":
1324 case "CODE-BLOCK":
1325 case "CODE_BLOCK":
1326 return this.createCodeBlock(node);
1327 case "P":
1328 return this.createParagraph(node);
1329 case "IMG":
1330 return this.createImage(node);
1331 case "INFOGRAPHIC":
1332 return this.createInfographic(node, slideId, layoutType);
1333 case "COLUMNS":
1334 return this.createColumns(node);
1335 case "BULLETS":
1336 return this.createBulletGroup(node);
1337 case "ICONS":
1338 return this.createIconList(node);
1339 case "CYCLE":
1340 return this.createCycle(node);
1341 case "STAIRCASE":
1342 return this.createStaircase(node);
1343 case "CHART":
1344 return this.createChart(node);
1345 case "ARROWS":
1346 return this.createArrowList(node);
1347 case "BOXES":
1348 return this.createBoxes(node);
1349 case "STEPS":
1350 return this.createSteps(node);
1351 case "COMPARE":
1352 return this.createCompare(node);
1353 case "BEFORE-AFTER":
1354 case "BEFOREAFTER":
1355 return this.createBeforeAfter(node);
1356 case "PROS-CONS":
1357 case "PROSCONS":
1358 return this.createProsCons(node);
1359 case "ARROW-SEQUENCE":
1360 case "ARROW_SEQUENCE":
1361 case "ARROW-VERTICAL":
1362 case "ARROW_VERTICAL":
1363 case "VERTICAL-ARROWS":
1364 case "VERTICAL_ARROWS":
1365 return this.createArrowVertical(node);
1366 case "TABLE":
1367 return this.createPlainTable(node);
1368 case "BUTTON":
1369 return this.createButton(node);
1370 case "PYRAMID":
1371 return this.createPyramid(node);
1372 case "TIMELINE":
1373 return this.createTimeline(node);
1374 case "STATS":
1375 return this.createStats(node);
1376 case "QUOTE":
1377 return this.createQuote(node);
1378 case "SLOPE":
1379 return this.createSlope(node);
1380 case "CONNECTED-CIRCLES":
1381 case "CONNECTED_CIRCLES":
1382 return this.createConnectedCircles(node);
1383 case "CIRCULAR-GRID":
1384 case "CIRCULAR_GRID":
1385 return this.createCircularGrid(node);
1386 case "SNAKE":
1387 return this.createSnake(node);
1388 default:
1389 console.warn(`Unknown top-level tag: ${tag}`);
1390 return null;
1391 }
1392 }
1393
1394 /**
1395 * Parse XML string into a structured tree with mixed content
1396 */
1397 private parseXML(xmlString: string): XMLNode {
1398 const rootNode: XMLNode = {
1399 tag: "ROOT",
1400 attributes: {},
1401 children: [],
1402 };
1403
1404 let processedXml = xmlString;
1405
1406 const presentationOpenStart = processedXml.indexOf("<PRESENTATION");
1407 if (presentationOpenStart !== -1) {
1408 const presentationOpenEnd = processedXml.indexOf(
1409 ">",
1410 presentationOpenStart,
1411 );
1412 if (presentationOpenEnd !== -1) {
1413 processedXml =
1414 processedXml.substring(0, presentationOpenStart) +
1415 processedXml.substring(presentationOpenEnd + 1);
1416 }
1417 }
1418
1419 processedXml = processedXml.replace("</PRESENTATION>", "");
1420
1421 try {
1422 let fixedXml = processedXml;
1423
1424 if (fixedXml.includes("<SECTION") && !fixedXml.endsWith("</SECTION>")) {
1425 fixedXml += "</SECTION>";
1426 }
1427
1428 this.parseElement(fixedXml, rootNode);
1429 } catch (error) {
1430 console.error("Error parsing XML:", error);
1431
1432 // Fall back to a very basic parser that just captures top level tags
1433 // First remove the PRESENTATION tags if present
1434 let withoutPresentation = xmlString;
1435
1436 // Handle opening tag with possible attributes
1437 const presentationOpenStart =
1438 withoutPresentation.indexOf("<PRESENTATION");
1439 if (presentationOpenStart !== -1) {
1440 const presentationOpenEnd = withoutPresentation.indexOf(
1441 ">",
1442 presentationOpenStart,
1443 );
1444 if (presentationOpenEnd !== -1) {
1445 // Remove the entire opening tag including attributes
1446 withoutPresentation =
1447 withoutPresentation.substring(0, presentationOpenStart) +
1448 withoutPresentation.substring(presentationOpenEnd + 1);
1449 }
1450 }
1451
1452 // Handle closing tag
1453 withoutPresentation = withoutPresentation.replace("</PRESENTATION>", "");
1454
1455 const sections = withoutPresentation.split(/<\/?SECTION[^>]*>/);
1456 let inSection = false;
1457
1458 for (const section of sections) {
1459 if (inSection && section.trim()) {
1460 // Create a synthetic section
1461 const sectionNode: XMLNode = {
1462 tag: "SECTION",
1463 attributes: {},
1464 children: [],
1465 };
1466
1467 rootNode.children.push(sectionNode);
1468 }
1469 inSection = !inSection;
1470 }
1471 }
1472
1473 return rootNode;
1474 }
1475
1476 /**
1477 * Enhanced parser that maintains order of text and elements
1478 */
1479 private parseElement(xml: string, parentNode: XMLNode): void {
1480 let currentIndex = 0;
1481
1482 while (currentIndex < xml.length) {
1483 const tagStart = xml.indexOf("<", currentIndex);
1484
1485 // No more tags, add remaining text
1486 if (tagStart === -1) {
1487 const remainingText = xml.substring(currentIndex);
1488 if (remainingText) {
1489 parentNode.children.push({ text: unescapeXmlText(remainingText) });
1490 }
1491 break;
1492 }
1493
1494 // Add text before tag
1495 if (tagStart > currentIndex) {
1496 const textContent = xml.substring(currentIndex, tagStart);
1497 if (textContent) {
1498 parentNode.children.push({ text: unescapeXmlText(textContent) });
1499 }
1500 }
1501
1502 const tagEnd = xml.indexOf(">", tagStart);
1503
1504 // Incomplete tag
1505 if (tagEnd === -1) {
1506 const remainingText = xml.substring(tagStart);
1507 if (remainingText) {
1508 parentNode.children.push({ text: unescapeXmlText(remainingText) });
1509 }
1510 break;
1511 }
1512
1513 const tagContent = xml.substring(tagStart + 1, tagEnd);
1514
1515 // Closing tag
1516 if (tagContent.startsWith("/")) {
1517 const closingTag = tagContent.substring(1);
1518 if (closingTag.toUpperCase() === parentNode.tag.toUpperCase()) {
1519 currentIndex = tagEnd + 1;
1520 break;
1521 } else {
1522 currentIndex = tagEnd + 1;
1523 continue;
1524 }
1525 }
1526
1527 // Comments
1528 if (tagContent.startsWith("!--")) {
1529 const commentEnd = xml.indexOf("-->", tagStart);
1530 currentIndex = commentEnd !== -1 ? commentEnd + 3 : xml.length;
1531 continue;
1532 }
1533
1534 // Parse tag name and attributes
1535 let tagName: string;
1536 let attrString: string;
1537
1538 const firstSpace = tagContent.indexOf(" ");
1539 if (firstSpace === -1) {
1540 tagName = tagContent;
1541 attrString = "";
1542 } else {
1543 tagName = tagContent.substring(0, firstSpace);
1544 attrString = tagContent.substring(firstSpace + 1);
1545 }
1546
1547 // Skip special tags
1548 if (tagName.startsWith("!") || tagName.startsWith("?")) {
1549 currentIndex = tagEnd + 1;
1550 continue;
1551 }
1552
1553 // Self-closing tag
1554 const isSelfClosing = tagContent.endsWith("/");
1555 if (isSelfClosing) {
1556 tagName = tagName.replace(/\/$/, "");
1557 }
1558
1559 // Parse attributes
1560 const attributes: Record<string, string> = {};
1561 let attrRemaining = attrString.trim();
1562
1563 while (attrRemaining.length > 0) {
1564 const eqIndex = attrRemaining.indexOf("=");
1565 if (eqIndex === -1) break;
1566
1567 const attrName = attrRemaining.substring(0, eqIndex).trim();
1568 attrRemaining = attrRemaining.substring(eqIndex + 1).trim();
1569
1570 let attrValue = "";
1571 const quoteChar = attrRemaining.charAt(0);
1572
1573 if (quoteChar === '"' || quoteChar === "'") {
1574 const endQuoteIndex = attrRemaining.indexOf(quoteChar, 1);
1575
1576 if (endQuoteIndex !== -1) {
1577 attrValue = attrRemaining.substring(1, endQuoteIndex);
1578 attrRemaining = attrRemaining.substring(endQuoteIndex + 1).trim();
1579 } else {
1580 attrValue = attrRemaining.substring(1);
1581 attrRemaining = "";
1582 }
1583 } else {
1584 const nextSpaceIndex = attrRemaining.indexOf(" ");
1585
1586 if (nextSpaceIndex !== -1) {
1587 attrValue = attrRemaining.substring(0, nextSpaceIndex);
1588 attrRemaining = attrRemaining.substring(nextSpaceIndex + 1).trim();
1589 } else {
1590 attrValue = attrRemaining;
1591 attrRemaining = "";
1592 }
1593 }
1594
1595 attributes[attrName] = unescapeXmlText(attrValue);
1596 }
1597
1598 // Create new node
1599 const newNode: XMLNode = {
1600 tag: tagName,
1601 attributes,
1602 children: [],
1603 originalTagContent: xml.substring(tagStart, tagEnd + 1),
1604 };
1605
1606 // Add to parent's children
1607 parentNode.children.push(newNode);
1608
1609 currentIndex = tagEnd + 1;
1610
1611 // If not self-closing, recursively parse
1612 if (!isSelfClosing) {
1613 this.parseElement(xml.substring(currentIndex), newNode);
1614
1615 const closingTag = `</${tagName}>`;
1616 const closingTagIndex = xml.indexOf(closingTag, currentIndex);
1617
1618 if (closingTagIndex !== -1) {
1619 currentIndex = closingTagIndex + closingTag.length;
1620 } else {
1621 break;
1622 }
1623 }
1624 }
1625 }
1626
1627 /**
1628 * Check if text should have generating mark
1629 */
1630 private shouldHaveGeneratingMark(text: string): boolean {
1631 const trimmedText = text.trim();
1632 if (!trimmedText) return false;
1633
1634 const textPos = this.latestContent.lastIndexOf(trimmedText);
1635 if (textPos === -1) return false;
1636
1637 const textEnd = textPos + trimmedText.length;
1638 if (textEnd >= this.latestContent.length) return true;
1639
1640 const afterText = this.latestContent.substring(textEnd).trim();
1641 return !afterText.startsWith("<");
1642 }
1643
1644 /**
1645 * Create a heading element
1646 */
1647 private createHeading(
1648 level: "h1" | "h2" | "h3" | "h4" | "h5" | "h6",
1649 node: XMLNode,
1650 ): HeadingElement {
1651 return {
1652 type: level,
1653 ...node.attributes,
1654 children: this.getTextDescendants(node),
1655 } as HeadingElement;
1656 }
1657
1658 /**
1659 * Create a paragraph element
1660 */
1661 private createParagraph(node: XMLNode): ParagraphElement {
1662 return {
1663 type: "p",
1664 ...node.attributes,
1665 children: this.getTextDescendants(node),
1666 } as ParagraphElement;
1667 }
1668
1669 /**
1670 * Create an image element
1671 */
1672 private createImage(node: XMLNode): ImageElement | null {
1673 if (!node.originalTagContent) {
1674 return null;
1675 }
1676
1677 const url = node.attributes.url ?? node.attributes.src ?? "";
1678
1679 const queryStart = node.originalTagContent.indexOf("query=");
1680
1681 if (queryStart === -1) {
1682 return null;
1683 }
1684
1685 const afterQuery = node.originalTagContent.substring(queryStart + 6);
1686 if (afterQuery.length === 0) {
1687 return null;
1688 }
1689
1690 const quoteChar = afterQuery[0];
1691 if (quoteChar !== '"' && quoteChar !== "'") {
1692 return null;
1693 }
1694
1695 const closingQuoteIdx = afterQuery.indexOf(quoteChar, 1);
1696
1697 if (closingQuoteIdx === -1) {
1698 return null;
1699 }
1700
1701 const query = afterQuery.substring(1, closingQuoteIdx);
1702
1703 if (!query || query.trim().length < 3) {
1704 return null;
1705 }
1706
1707 return {
1708 type: "img",
1709 ...node.attributes,
1710 url: url,
1711 query: query,
1712 ...(url ? { imageSource: "search" as const } : {}),
1713 children: [{ text: "" } as TText],
1714 } as ImageElement;
1715 }
1716
1717 /**
1718 * Create a columns layout element
1719 */
1720 private createColumns(node: XMLNode): TColumnGroupElement {
1721 const columnItems: TColumnElement[] = [];
1722
1723 for (const child of node.children) {
1724 if (isElementNode(child) && child.tag.toUpperCase() === "DIV") {
1725 const columnItem: TColumnElement = {
1726 type: ColumnItemPlugin.key,
1727 ...child.attributes,
1728 width: child.attributes.width ?? "",
1729 children: this.processNodes(child.children) as Descendant[],
1730 };
1731 columnItems.push(columnItem);
1732 }
1733 }
1734
1735 return {
1736 type: ColumnPlugin.key,
1737 ...node.attributes,
1738 children: columnItems,
1739 } as TColumnGroupElement;
1740 }
1741
1742 /**
1743 * Process a DIV node
1744 */
1745 private processDiv(node: XMLNode): PlateNode | null {
1746 const children = this.processNodes(node.children);
1747
1748 if (children.length === 0) {
1749 const textContent = this.getTextContent(node);
1750 return {
1751 type: "p",
1752 ...node.attributes,
1753 children: [
1754 {
1755 text: textContent,
1756 ...(this.shouldHaveGeneratingMark(textContent)
1757 ? { generating: true }
1758 : {}),
1759 } as TText,
1760 ],
1761 } as ParagraphElement;
1762 } else if (children.length === 1) {
1763 return children[0] ?? null;
1764 } else {
1765 return {
1766 type: "p",
1767 ...node.attributes,
1768 children: children as Descendant[],
1769 } as ParagraphElement;
1770 }
1771 }
1772
1773 /**
1774 * Create a bullets layout element
1775 */
1776 private createBulletGroup(node: XMLNode): TBulletGroupElement {
1777 const bulletItems: TBulletItemElement[] = [];
1778
1779 for (const child of node.children) {
1780 if (isElementNode(child) && child.tag.toUpperCase() === "DIV") {
1781 const icon = this.extractIconValue(child);
1782 const bulletItem: TBulletItemElement = {
1783 type: "bullet",
1784 ...child.attributes,
1785 ...(icon ? { icon } : {}),
1786 children: this.processNodes(child.children) as Descendant[],
1787 };
1788 bulletItems.push(bulletItem);
1789 }
1790 }
1791
1792 return {
1793 type: "bullets",
1794 ...node.attributes,
1795 children: bulletItems,
1796 } as TBulletGroupElement;
1797 }
1798
1799 /**
1800 * Create an icons layout element
1801 */
1802 private createIconList(node: XMLNode): TIconListElement {
1803 const iconItems: TIconListItemElement[] = [];
1804
1805 for (const child of node.children) {
1806 if (isElementNode(child) && child.tag.toUpperCase() === "DIV") {
1807 let icon = this.extractIconValue(child);
1808 const children: Descendant[] = [];
1809
1810 for (const iconChild of child.children) {
1811 if (!isElementNode(iconChild)) continue;
1812
1813 if (iconChild.tag.toUpperCase() === "ICON") {
1814 icon ||= this.extractIconValue(iconChild);
1815 continue;
1816 }
1817
1818 const processedChild = this.processNode(iconChild);
1819 if (processedChild) {
1820 children.push(processedChild as Descendant);
1821 }
1822 }
1823
1824 const iconItem: TIconListItemElement = {
1825 type: "icon-item",
1826 ...child.attributes,
1827 ...(icon ? { icon } : {}),
1828 ...(child.attributes.prompt
1829 ? { prompt: child.attributes.prompt }
1830 : {}),
1831 children,
1832 };
1833 iconItems.push(iconItem);
1834 }
1835 }
1836
1837 return {
1838 type: "icons",
1839 ...node.attributes,
1840 ...(node.attributes.mediaSize
1841 ? { mediaSize: Number.parseFloat(node.attributes.mediaSize) }
1842 : {}),
1843 children: iconItems,
1844 } as TIconListElement;
1845 }
1846
1847 private extractIconValue(node: XMLNode): string {
1848 const rawValue =
1849 node.attributes.icon ??
1850 node.attributes.name ??
1851 node.attributes.query ??
1852 "";
1853
1854 if (!rawValue) return "";
1855
1856 let sanitizedValue = rawValue;
1857
1858 if (
1859 sanitizedValue.includes("<") ||
1860 sanitizedValue.includes(">") ||
1861 sanitizedValue.includes("</") ||
1862 sanitizedValue.includes("SECTION")
1863 ) {
1864 const tagIndex = Math.min(
1865 sanitizedValue.indexOf("<") !== -1
1866 ? sanitizedValue.indexOf("<")
1867 : Infinity,
1868 sanitizedValue.indexOf(">") !== -1
1869 ? sanitizedValue.indexOf(">")
1870 : Infinity,
1871 sanitizedValue.indexOf("</") !== -1
1872 ? sanitizedValue.indexOf("</")
1873 : Infinity,
1874 sanitizedValue.indexOf("SECTION") !== -1
1875 ? sanitizedValue.indexOf("SECTION")
1876 : Infinity,
1877 );
1878
1879 sanitizedValue = sanitizedValue.substring(0, tagIndex).trim();
1880 }
1881
1882 return sanitizedValue.trim().length >= 2 ? sanitizedValue.trim() : "";
1883 }
1884
1885 /**
1886 * Create a cycle layout element
1887 */
1888 private createCycle(node: XMLNode): TCycleGroupElement {
1889 const cycleItems: TCycleItemElement[] = [];
1890
1891 for (const child of node.children) {
1892 if (isElementNode(child) && child.tag.toUpperCase() === "DIV") {
1893 const icon = this.extractIconValue(child);
1894 const cycleItem: TCycleItemElement = {
1895 type: "cycle-item",
1896 ...child.attributes,
1897 ...(icon ? { icon } : {}),
1898 children: this.processNodes(child.children) as Descendant[],
1899 };
1900 cycleItems.push(cycleItem);
1901 }
1902 }
1903
1904 return {
1905 type: "cycle",
1906 ...node.attributes,
1907 children: cycleItems,
1908 } as TCycleGroupElement;
1909 }
1910
1911 /**
1912 * Create a staircase layout element
1913 */
1914 private createStaircase(node: XMLNode): TStairGroupElement {
1915 const stairItems: TStairItemElement[] = [];
1916
1917 for (const child of node.children) {
1918 if (isElementNode(child) && child.tag.toUpperCase() === "DIV") {
1919 const icon = this.extractIconValue(child);
1920 const stairItem: TStairItemElement = {
1921 type: "stair-item",
1922 ...child.attributes,
1923 ...(icon ? { icon } : {}),
1924 children: this.processNodes(child.children) as Descendant[],
1925 };
1926 stairItems.push(stairItem);
1927 }
1928 }
1929
1930 return {
1931 type: "staircase",
1932 ...node.attributes,
1933 children: stairItems,
1934 } as TStairGroupElement;
1935 }
1936
1937 /**
1938 * Create a steps layout element
1939 */
1940 private createSteps(node: XMLNode): TStepsGroupElement {
1941 const stepsItems: TStepsItemElement[] = [];
1942
1943 for (const child of node.children) {
1944 if (isElementNode(child) && child.tag.toUpperCase() === "DIV") {
1945 const icon = this.extractIconValue(child);
1946 const stepsItem: TStepsItemElement = {
1947 type: "steps-item",
1948 ...child.attributes,
1949 ...(icon ? { icon } : {}),
1950 children: this.processNodes(child.children) as Descendant[],
1951 };
1952 stepsItems.push(stepsItem);
1953 }
1954 }
1955
1956 return {
1957 type: "steps",
1958 ...node.attributes,
1959 children: stepsItems,
1960 } as TStepsGroupElement;
1961 }
1962
1963 /**
1964 * Create an arrows layout element
1965 */
1966 private createArrowList(node: XMLNode): TArrowListElement {
1967 const arrowItems: TArrowListItemElement[] = [];
1968
1969 for (const child of node.children) {
1970 if (isElementNode(child) && child.tag.toUpperCase() === "DIV") {
1971 const icon = this.extractIconValue(child);
1972 const itemChildren: Descendant[] = [];
1973
1974 for (const divChild of child.children) {
1975 if (isTextNode(divChild)) {
1976 if (divChild.text.trim()) {
1977 itemChildren.push({
1978 text: divChild.text,
1979 ...(this.shouldHaveGeneratingMark(divChild.text)
1980 ? { generating: true }
1981 : {}),
1982 } as TText);
1983 }
1984 } else if (isElementNode(divChild)) {
1985 const processedChild = this.processNode(divChild);
1986 if (processedChild) {
1987 itemChildren.push(processedChild as Descendant);
1988 }
1989 }
1990 }
1991
1992 if (itemChildren.length > 0) {
1993 arrowItems.push({
1994 type: "arrow-item",
1995 ...child.attributes,
1996 ...(icon ? { icon } : {}),
1997 children: itemChildren,
1998 } as TArrowListItemElement);
1999 }
2000 }
2001 }
2002
2003 return {
2004 type: "arrows",
2005 ...node.attributes,
2006 children:
2007 arrowItems.length > 0
2008 ? arrowItems
2009 : ([{ text: "" } as TText] as Descendant[]),
2010 } as TArrowListElement;
2011 }
2012
2013 /**
2014 * Create a pyramid layout element
2015 */
2016 private createPyramid(node: XMLNode): TPyramidGroupElement {
2017 const pyramidItems: TPyramidItemElement[] = [];
2018
2019 for (const child of node.children) {
2020 if (isElementNode(child) && child.tag.toUpperCase() === "DIV") {
2021 const icon = this.extractIconValue(child);
2022 const pyramidItem: TPyramidItemElement = {
2023 type: "pyramid-item",
2024 ...child.attributes,
2025 ...(icon ? { icon } : {}),
2026 children: this.processNodes(child.children) as Descendant[],
2027 };
2028 pyramidItems.push(pyramidItem);
2029 }
2030 }
2031
2032 return {
2033 type: "pyramid",
2034 ...node.attributes,
2035 children: pyramidItems,
2036 } as TPyramidGroupElement;
2037 }
2038
2039 /**
2040 * Create Boxes layout
2041 */
2042 private createBoxes(node: XMLNode): TBoxGroupElement {
2043 const items: TBoxItemElement[] = [];
2044 for (const child of node.children) {
2045 if (isElementNode(child) && child.tag.toUpperCase() === "DIV") {
2046 items.push({
2047 type: "box-item",
2048 ...child.attributes,
2049 children: this.processNodes(child.children) as Descendant[],
2050 } as TBoxItemElement);
2051 }
2052 }
2053 return {
2054 type: "boxes",
2055 ...node.attributes,
2056 children: items,
2057 } as TBoxGroupElement;
2058 }
2059
2060 /**
2061 * Create Compare layout
2062 */
2063 private createCompare(node: XMLNode): TCompareGroupElement {
2064 const sides: TCompareSideElement[] = [];
2065 for (const child of node.children) {
2066 if (isElementNode(child) && child.tag.toUpperCase() === "DIV") {
2067 sides.push({
2068 type: "compare-side",
2069 ...child.attributes,
2070 children: this.processNodes(child.children) as Descendant[],
2071 } as TCompareSideElement);
2072 }
2073 }
2074 return {
2075 type: "compare",
2076 ...node.attributes,
2077 children: sides,
2078 } as TCompareGroupElement;
2079 }
2080
2081 /**
2082 * Create Before/After layout
2083 */
2084 private createBeforeAfter(node: XMLNode): TBeforeAfterGroupElement {
2085 const sides: TBeforeAfterSideElement[] = [];
2086 for (const child of node.children) {
2087 if (isElementNode(child) && child.tag.toUpperCase() === "DIV") {
2088 sides.push({
2089 type: "before-after-side",
2090 ...child.attributes,
2091 children: this.processNodes(child.children) as Descendant[],
2092 } as TBeforeAfterSideElement);
2093 }
2094 }
2095 return {
2096 type: "before-after",
2097 ...node.attributes,
2098 children: sides,
2099 } as TBeforeAfterGroupElement;
2100 }
2101
2102 /**
2103 * Create Pros/Cons layout
2104 */
2105 private createProsCons(node: XMLNode): TProsConsGroupElement {
2106 const children: (TProsItemElement | TConsItemElement)[] = [];
2107 for (const child of node.children) {
2108 if (!isElementNode(child)) continue;
2109
2110 if (child.tag.toUpperCase() === "PROS") {
2111 children.push({
2112 type: "pros-item",
2113 ...child.attributes,
2114 children: this.processNodes(child.children) as Descendant[],
2115 } as TProsItemElement);
2116 } else if (child.tag.toUpperCase() === "CONS") {
2117 children.push({
2118 type: "cons-item",
2119 ...child.attributes,
2120 children: this.processNodes(child.children) as Descendant[],
2121 } as TConsItemElement);
2122 } else if (child.tag.toUpperCase() === "DIV") {
2123 const isPros = children.length % 2 === 0;
2124 children.push({
2125 type: isPros ? "pros-item" : "cons-item",
2126 ...child.attributes,
2127 children: this.processNodes(child.children) as Descendant[],
2128 } as unknown as TProsItemElement);
2129 }
2130 }
2131 return {
2132 type: "pros-cons",
2133 ...node.attributes,
2134 children,
2135 } as TProsConsGroupElement;
2136 }
2137
2138 /**
2139 * Create Vertical Arrow layout
2140 */
2141 private createArrowVertical(node: XMLNode): TSequenceArrowGroupElement {
2142 const items: TSequenceArrowItemElement[] = [];
2143 for (const child of node.children) {
2144 if (isElementNode(child) && child.tag.toUpperCase() === "DIV") {
2145 items.push({
2146 type: "arrow-vertical-item",
2147 ...child.attributes,
2148 children: this.processNodes(child.children) as Descendant[],
2149 } as TSequenceArrowItemElement);
2150 }
2151 }
2152 return {
2153 type: "arrow-vertical",
2154 ...node.attributes,
2155 children: items,
2156 } as TSequenceArrowGroupElement;
2157 }
2158
2159 /**
2160 * Create Stats layout for displaying metrics/KPIs
2161 */
2162 private createStats(node: XMLNode): TStatsGroupElement {
2163 const items: TStatsItemElement[] = [];
2164 for (const child of node.children) {
2165 if (isElementNode(child) && child.tag.toUpperCase() === "DIV") {
2166 items.push({
2167 type: "stats-item",
2168 stat: child.attributes.stat || "0",
2169 ...child.attributes,
2170 children: this.processNodes(child.children) as Descendant[],
2171 } as TStatsItemElement);
2172 }
2173 }
2174 return {
2175 type: "stats",
2176 statsType:
2177 (node.attributes.statstype as TStatsGroupElement["statsType"]) ||
2178 "plain",
2179 ...node.attributes,
2180 children: items,
2181 } as TStatsGroupElement;
2182 }
2183
2184 /**
2185 * Create a simple Table layout
2186 */
2187 private createPlainTable(node: XMLNode): TTableElement {
2188 const rows: TTableRowElement[] = [];
2189
2190 const parseRow = (rowNode: XMLNode): void => {
2191 if (!rowNode) return;
2192 const cells: TTableCellElement[] = [];
2193
2194 for (const cellNode of rowNode.children) {
2195 if (!isElementNode(cellNode)) continue;
2196
2197 const tag = cellNode.tag.toUpperCase();
2198 if (tag === "TD" || tag === "TH") {
2199 const isCellHeader = tag === "TH";
2200
2201 const cellChildren = this.processNodes(
2202 cellNode.children,
2203 ) as Descendant[];
2204
2205 const colSpanStr =
2206 cellNode.attributes.colspan || cellNode.attributes.colSpan;
2207 const rowSpanStr =
2208 cellNode.attributes.rowspan || cellNode.attributes.rowSpan;
2209
2210 const colSpanVal = colSpanStr ? parseInt(colSpanStr, 10) : undefined;
2211 const rowSpanVal = rowSpanStr ? parseInt(rowSpanStr, 10) : undefined;
2212
2213 const background =
2214 cellNode.attributes.background || cellNode.attributes.bg;
2215
2216 const extraProps: {
2217 colSpan?: number;
2218 rowSpan?: number;
2219 background?: string;
2220 } = {};
2221 if (colSpanVal && colSpanVal > 1) extraProps.colSpan = colSpanVal;
2222 if (rowSpanVal && rowSpanVal > 1) extraProps.rowSpan = rowSpanVal;
2223 if (background) extraProps.background = background;
2224
2225 const cell = {
2226 type: isCellHeader ? "th" : "td",
2227 ...cellNode.attributes,
2228 ...extraProps,
2229 children:
2230 cellChildren.length > 0
2231 ? cellChildren
2232 : ([
2233 {
2234 type: "p",
2235 children: [
2236 {
2237 text: this.getTextContent(cellNode).trim() || "",
2238 } as TText,
2239 ],
2240 },
2241 ] as unknown as Descendant[]),
2242 } as unknown as TTableCellElement;
2243
2244 cells.push(cell);
2245 }
2246 }
2247
2248 rows.push({
2249 type: "tr",
2250 ...rowNode.attributes,
2251 children: cells,
2252 } as TTableRowElement);
2253 };
2254
2255 for (const child of node.children) {
2256 if (!isElementNode(child)) continue;
2257
2258 const tag = child.tag.toUpperCase();
2259 if (tag === "THEAD") {
2260 for (const row of child.children) {
2261 if (!isElementNode(row)) continue;
2262 const rowTag = row.tag.toUpperCase();
2263 if (rowTag === "TR" || rowTag === "ROW") parseRow(row);
2264 }
2265 }
2266 }
2267
2268 const directRows: XMLNode[] = [];
2269 const bodyRows: XMLNode[] = [];
2270 for (const child of node.children) {
2271 if (!isElementNode(child)) continue;
2272
2273 const tag = child.tag.toUpperCase();
2274 if (tag === "TBODY") {
2275 for (const row of child.children) {
2276 if (!isElementNode(row)) continue;
2277 const rowTag = row.tag.toUpperCase();
2278 if (rowTag === "TR" || rowTag === "ROW") bodyRows.push(row);
2279 }
2280 } else if (tag === "TR" || tag === "ROW") {
2281 directRows.push(child);
2282 }
2283 }
2284
2285 const remainingRows: XMLNode[] = [...directRows, ...bodyRows];
2286
2287 for (let i = 0; i < remainingRows.length; i++) {
2288 const row = remainingRows[i]!;
2289 parseRow(row);
2290 }
2291
2292 return {
2293 type: "table",
2294 ...node.attributes,
2295 children: rows,
2296 } as TTableElement;
2297 }
2298
2299 /**
2300 * Create a timeline layout element
2301 */
2302 private createTimeline(node: XMLNode): TTimelineGroupElement {
2303 const timelineItems: TTimelineItemElement[] = [];
2304 const orientation =
2305 parseOrientationAttribute(node.attributes.orientation) ?? "vertical";
2306 const sidedness =
2307 parseSidednessAttribute(node.attributes.sidedness) ?? "single";
2308 const numbered = parseBooleanAttribute(node.attributes.numbered) ?? true;
2309 const showLine = parseBooleanAttribute(node.attributes.showLine) ?? true;
2310 const alignment = parseAlignmentAttribute(node.attributes.alignment);
2311 const variant =
2312 node.attributes.variant === "boxes" ||
2313 node.attributes.variant === "default"
2314 ? node.attributes.variant
2315 : undefined;
2316
2317 for (const child of node.children) {
2318 if (isElementNode(child) && child.tag.toUpperCase() === "DIV") {
2319 const icon = this.extractIconValue(child);
2320 const itemChildren: Descendant[] = [];
2321
2322 for (const divChild of child.children) {
2323 if (isTextNode(divChild)) {
2324 if (divChild.text.trim()) {
2325 itemChildren.push({
2326 text: divChild.text,
2327 ...(this.shouldHaveGeneratingMark(divChild.text)
2328 ? { generating: true }
2329 : {}),
2330 } as TText);
2331 }
2332 } else if (isElementNode(divChild)) {
2333 const processedChild = this.processNode(divChild);
2334 if (processedChild) {
2335 itemChildren.push(processedChild as Descendant);
2336 }
2337 }
2338 }
2339
2340 if (itemChildren.length > 0) {
2341 timelineItems.push({
2342 type: "timeline-item",
2343 ...child.attributes,
2344 ...(icon ? { icon } : {}),
2345 children: itemChildren,
2346 } as TTimelineItemElement);
2347 }
2348 }
2349 }
2350
2351 return {
2352 type: "timeline",
2353 ...node.attributes,
2354 orientation,
2355 sidedness,
2356 numbered,
2357 showLine,
2358 ...(alignment ? { alignment } : {}),
2359 ...(variant ? { variant } : {}),
2360 children:
2361 timelineItems.length > 0
2362 ? timelineItems
2363 : ([{ text: "" } as TText] as Descendant[]),
2364 } as TTimelineGroupElement;
2365 }
2366
2367 /**
2368 * Create a chart element
2369 */
2370 private createChart(node: XMLNode): PlateNode {
2371 const chartType = (node.attributes.charttype || "bar").toLowerCase();
2372 const options = this.parseJsonChild(node, "OPTIONS");
2373 const parsedData = this.parseChartDataRows(node);
2374 const elementType = getChartElementType(chartType);
2375 const attributeOptions = this.getOptionsFromAttributes(node, ["charttype"]);
2376 const structuredOptions = isRecord(options) ? options : {};
2377
2378 return {
2379 type: elementType,
2380 ...attributeOptions,
2381 ...structuredOptions,
2382 data: parsedData,
2383 children: [{ text: "" } as TText],
2384 } as PlateNode;
2385 }
2386
2387 private createPresentationTitle(node: XMLNode): TPresentationTitleElement {
2388 const alignment = parseAlignmentAttribute(node.attributes.alignment);
2389 const variant = parsePresentationTitleVariant(node.attributes.variant);
2390 const children = this.getTextDescendants(node);
2391
2392 return {
2393 type: PRESENTATION_TITLE_ELEMENT,
2394 ...node.attributes,
2395 ...(alignment ? { alignment } : {}),
2396 variant,
2397 children,
2398 } as TPresentationTitleElement;
2399 }
2400
2401 private createLabel(node: XMLNode): TLabelElement {
2402 const alignment = parseAlignmentAttribute(node.attributes.alignment);
2403 const children = this.getTextDescendants(node);
2404
2405 return {
2406 type: LABEL_ELEMENT,
2407 ...node.attributes,
2408 ...(alignment ? { alignment } : {}),
2409 children,
2410 } as TLabelElement;
2411 }
2412
2413 private createContributor(_node: XMLNode): TContributorElement {
2414 return {
2415 type: CONTRIBUTOR_ELEMENT,
2416 children: [{ text: "" } as TText],
2417 } as TContributorElement;
2418 }
2419
2420 private createBlockquote(node: XMLNode): TElement {
2421 return {
2422 type: KEYS.blockquote,
2423 ...node.attributes,
2424 children: this.getTextDescendants(node),
2425 } as TElement;
2426 }
2427
2428 private createCallout(node: XMLNode): TElement {
2429 const alignment = parseAlignmentAttribute(node.attributes.alignment);
2430 const children = this.processNodes(node.children) as Descendant[];
2431 const fallbackText = this.getTextContent(node).trim();
2432 const finalChildren =
2433 children.length > 0
2434 ? children
2435 : ([
2436 {
2437 type: KEYS.p,
2438 children: [{ text: fallbackText } as TText],
2439 },
2440 ] as unknown as Descendant[]);
2441
2442 return {
2443 type: KEYS.callout,
2444 ...node.attributes,
2445 ...(alignment ? { alignment } : {}),
2446 children: finalChildren,
2447 } as TElement;
2448 }
2449
2450 private createCodeBlock(node: XMLNode): TElement {
2451 const code = this.getTextContent(node).replace(/^\n+|\n+$/g, "");
2452 const lines = code.split(/\r?\n/);
2453 const language = node.attributes.language ?? node.attributes.lang;
2454
2455 return {
2456 type: KEYS.codeBlock,
2457 ...(language ? { lang: language } : {}),
2458 children: lines.map(
2459 (line) =>
2460 ({
2461 type: KEYS.codeLine,
2462 children: [{ text: line } as TText],
2463 }) as TElement,
2464 ),
2465 } as TElement;
2466 }
2467
2468 /**
2469 * Create a non-functional themed Button element
2470 */
2471 private createButton(node: XMLNode): PlateNode {
2472 const variantAttr = (node.attributes.variant || "").toLowerCase();
2473 const sizeAttr = (node.attributes.size || "").toLowerCase();
2474
2475 const variant: "filled" | "outline" | "ghost" | undefined =
2476 variantAttr === "filled" ||
2477 variantAttr === "outline" ||
2478 variantAttr === "ghost"
2479 ? (variantAttr as "filled" | "outline" | "ghost")
2480 : undefined;
2481
2482 const size: "sm" | "md" | "lg" | undefined =
2483 sizeAttr === "sm" || sizeAttr === "md" || sizeAttr === "lg"
2484 ? (sizeAttr as "sm" | "md" | "lg")
2485 : undefined;
2486 const alignment = parseAlignmentAttribute(node.attributes.alignment);
2487
2488 const children = this.processNodes(node.children) as Descendant[];
2489 const fallback = this.getTextContent(node).trim() || "";
2490 const finalChildren =
2491 children.length > 0
2492 ? children
2493 : ([{ text: fallback }] as unknown as Descendant[]);
2494
2495 return {
2496 type: "button",
2497 ...node.attributes,
2498 ...(variant ? { variant } : {}),
2499 ...(size ? { size } : {}),
2500 ...(alignment ? { alignment } : {}),
2501 children: finalChildren,
2502 } as unknown as PlateNode;
2503 }
2504
2505 /**
2506 * Extract text descendants from a node, processing inline formatting
2507 * This is the KEY method that maintains order of text and elements
2508 */
2509 private getTextDescendants(node: XMLNode): Descendant[] {
2510 const descendants: Descendant[] = [];
2511
2512 for (const child of node.children) {
2513 if (isTextNode(child)) {
2514 // Direct text node
2515 if (child.text) {
2516 descendants.push({
2517 text: child.text,
2518 ...(this.shouldHaveGeneratingMark(child.text)
2519 ? { generating: true }
2520 : {}),
2521 } as TText);
2522 }
2523 } else if (isElementNode(child)) {
2524 const childTag = child.tag.toUpperCase();
2525
2526 // Handle inline formatting elements
2527 if (childTag === "B" || childTag === "STRONG") {
2528 const content = this.getTextContent(child);
2529 descendants.push({
2530 text: content,
2531 bold: true,
2532 ...(this.shouldHaveGeneratingMark(content)
2533 ? { generating: true }
2534 : {}),
2535 } as Descendant);
2536 } else if (childTag === "I" || childTag === "EM") {
2537 const content = this.getTextContent(child);
2538 descendants.push({
2539 text: content,
2540 italic: true,
2541 ...(this.shouldHaveGeneratingMark(content)
2542 ? { generating: true }
2543 : {}),
2544 } as Descendant);
2545 } else if (childTag === "U") {
2546 const content = this.getTextContent(child);
2547 descendants.push({
2548 text: content,
2549 underline: true,
2550 ...(this.shouldHaveGeneratingMark(content)
2551 ? { generating: true }
2552 : {}),
2553 } as Descendant);
2554 } else if (childTag === "S" || childTag === "STRIKE") {
2555 const content = this.getTextContent(child);
2556 descendants.push({
2557 text: content,
2558 strikethrough: true,
2559 ...(this.shouldHaveGeneratingMark(content)
2560 ? { generating: true }
2561 : {}),
2562 } as Descendant);
2563 } else {
2564 // For other elements, recursively process them
2565 const processedChild = this.processNode(child);
2566 if (processedChild) {
2567 descendants.push(processedChild as Descendant);
2568 }
2569 }
2570 }
2571 }
2572
2573 // Return empty text node if no descendants
2574 return descendants.length > 0 ? descendants : [{ text: "" } as TText];
2575 }
2576
2577 /**
2578 * Get the complete text content of a node (flattened)
2579 */
2580 private getTextContent(node: XMLNode): string {
2581 let text = "";
2582
2583 for (const child of node.children) {
2584 if (isTextNode(child)) {
2585 text += child.text;
2586 } else if (isElementNode(child)) {
2587 text += this.getTextContent(child);
2588 }
2589 }
2590
2591 return text;
2592 }
2593
2594 private getDirectTextContent(node: XMLNode): string {
2595 return node.children
2596 .filter(isTextNode)
2597 .map((child) => child.text)
2598 .join("\n");
2599 }
2600
2601 /**
2602 * Process a list of XMLNodes into Plate elements
2603 */
2604 private processNodes(nodes: Array<XMLNode | XMLTextNode>): PlateNode[] {
2605 const plateNodes: PlateNode[] = [];
2606
2607 for (let i = 0; i < nodes.length; ) {
2608 const node = nodes[i];
2609 if (!node) {
2610 i += 1;
2611 continue;
2612 }
2613
2614 // Skip text nodes at this level (they're handled by getTextDescendants)
2615 if (isTextNode(node)) {
2616 i += 1;
2617 continue;
2618 }
2619
2620 const tag = node.tag.toUpperCase();
2621
2622 // Group consecutive <LI> siblings
2623 if (tag === "LI") {
2624 const liNodes: XMLNode[] = [];
2625 let j = i;
2626 while (j < nodes.length) {
2627 const candidate = nodes[j];
2628 if (!candidate || !isElementNode(candidate)) break;
2629 if (candidate.tag.toUpperCase() !== "LI") break;
2630 liNodes.push(candidate);
2631 j += 1;
2632 }
2633 const listItems = this.createListItemsFromLiNodes(liNodes);
2634 for (const item of listItems) plateNodes.push(item);
2635 i = j;
2636 continue;
2637 }
2638
2639 // Default: process normally
2640 const processedNode = this.processNode(node);
2641 if (processedNode) {
2642 plateNodes.push(processedNode);
2643 }
2644 i += 1;
2645 }
2646
2647 return plateNodes;
2648 }
2649
2650 /**
2651 * Process a single XMLNode into a Plate element
2652 */
2653 private processNode(node: XMLNode): PlateNode | null {
2654 const tag = node.tag.toUpperCase();
2655
2656 switch (tag) {
2657 case "H1":
2658 case "H2":
2659 case "H3":
2660 case "H4":
2661 case "H5":
2662 case "H6":
2663 return this.createHeading(
2664 tag.toLowerCase() as "h1" | "h2" | "h3" | "h4" | "h5" | "h6",
2665 node,
2666 );
2667 case "TITLE":
2668 case "PRESENTATION-TITLE":
2669 case "PRESENTATION_TITLE":
2670 return this.createPresentationTitle(node);
2671 case "LABEL":
2672 return this.createLabel(node);
2673 case "CONTRIBUTOR":
2674 return this.createContributor(node);
2675 case "BLOCKQUOTE":
2676 return this.createBlockquote(node);
2677 case "CALLOUT":
2678 return this.createCallout(node);
2679 case "CODE":
2680 case "CODE-BLOCK":
2681 case "CODE_BLOCK":
2682 return this.createCodeBlock(node);
2683 case "P":
2684 return this.createParagraph(node);
2685 case "IMG":
2686 return this.createImage(node);
2687 case "INFOGRAPHIC":
2688 return this.createInfographic(node, "nested");
2689 case "COLUMNS":
2690 return this.createColumns(node);
2691 case "DIV":
2692 return this.processDiv(node);
2693 case "BULLETS":
2694 return this.createBulletGroup(node);
2695 case "ICONS":
2696 return this.createIconList(node);
2697 case "CYCLE":
2698 return this.createCycle(node);
2699 case "STAIRCASE":
2700 return this.createStaircase(node);
2701 case "CHART":
2702 return this.createChart(node);
2703 case "ARROWS":
2704 return this.createArrowList(node);
2705 case "BOXES":
2706 return this.createBoxes(node);
2707 case "COMPARE":
2708 return this.createCompare(node);
2709 case "BEFORE-AFTER":
2710 case "BEFOREAFTER":
2711 return this.createBeforeAfter(node);
2712 case "PROS-CONS":
2713 case "PROSCONS":
2714 return this.createProsCons(node);
2715 case "LI":
2716 return this.createListItemsFromLiNodes([node])[0] ?? null;
2717 case "PYRAMID":
2718 return this.createPyramid(node);
2719 case "STEPS":
2720 return this.createSteps(node);
2721 case "TIMELINE":
2722 return this.createTimeline(node);
2723 case "STATS":
2724 return this.createStats(node);
2725 case "ARROW-SEQUENCE":
2726 case "ARROW_SEQUENCE":
2727 case "ARROW-VERTICAL":
2728 case "ARROW_VERTICAL":
2729 case "VERTICAL-ARROWS":
2730 case "VERTICAL_ARROWS":
2731 return this.createArrowVertical(node);
2732 case "ICON":
2733 return null;
2734 case "BUTTON":
2735 return this.createButton(node);
2736 case "QUOTE":
2737 return this.createQuote(node);
2738 case "SLOPE":
2739 return this.createSlope(node);
2740 case "CONNECTED-CIRCLES":
2741 case "CONNECTED_CIRCLES":
2742 return this.createConnectedCircles(node);
2743 case "CIRCULAR-GRID":
2744 case "CIRCULAR_GRID":
2745 return this.createCircularGrid(node);
2746 case "SNAKE":
2747 return this.createSnake(node);
2748 default:
2749 if (node.children.length > 0) {
2750 const children = this.processNodes(node.children);
2751 if (children.length > 0) {
2752 return {
2753 type: "p",
2754 ...node.attributes,
2755 children: children as Descendant[],
2756 } as ParagraphElement;
2757 }
2758 }
2759 return null;
2760 }
2761 }
2762
2763 /**
2764 * Create a quote element
2765 */
2766 private createQuote(node: XMLNode): TQuoteElement {
2767 const variant =
2768 (node.attributes.variant as "large" | "sidequote-icon" | "sidequote") ??
2769 "large";
2770 const author = node.attributes.author ?? "";
2771
2772 const text = this.getTextContent(node).trim();
2773 const children: Descendant[] = text
2774 ? [
2775 {
2776 text,
2777 ...(this.shouldHaveGeneratingMark(text)
2778 ? { generating: true }
2779 : {}),
2780 } as TText,
2781 ]
2782 : [{ text: "" } as TText];
2783
2784 return {
2785 type: QUOTE_ELEMENT,
2786 ...node.attributes,
2787 variant,
2788 author,
2789 children,
2790 } as TQuoteElement;
2791 }
2792
2793 /**
2794 * Convert <LI> nodes into Plate list paragraph elements
2795 */
2796 private createListItemsFromLiNodes(
2797 liNodes: XMLNode[],
2798 isOrdered = false,
2799 ): ParagraphElement[] {
2800 const items: ParagraphElement[] = [];
2801
2802 for (const li of liNodes) {
2803 let itemChildren = this.processNodes(li.children) as Descendant[];
2804 const contentText = this.getTextContent(li).trim();
2805
2806 if ((!itemChildren || itemChildren.length === 0) && contentText) {
2807 itemChildren = [
2808 {
2809 text: contentText,
2810 ...(this.shouldHaveGeneratingMark(contentText)
2811 ? { generating: true }
2812 : {}),
2813 } as TText,
2814 ] as unknown as Descendant[];
2815 }
2816
2817 if (!itemChildren || itemChildren.length === 0) {
2818 itemChildren = [{ text: "" } as TText] as unknown as Descendant[];
2819 }
2820
2821 items.push({
2822 type: "p",
2823 ...li.attributes,
2824 children: itemChildren,
2825 indent: 1,
2826 listStyleType: isOrdered ? "decimal" : "disc",
2827 } as unknown as ParagraphElement);
2828 }
2829
2830 return items;
2831 }
2832
2833 /**
2834 * Create a slope layout element
2835 */
2836 private createSlope(node: XMLNode): TSlopeGroupElement {
2837 const slopeItems: TSlopeItemElement[] = [];
2838
2839 for (const child of node.children) {
2840 if (isElementNode(child) && child.tag.toUpperCase() === "DIV") {
2841 const icon = this.extractIconValue(child);
2842
2843 // Slope items can ONLY hold headings of very small levels (e.g. H4) and NO description.
2844 // We extract text content and wrap it in a single h4 element, discarding any other tags.
2845 let textContent = "";
2846 for (const gc of child.children) {
2847 if (isTextNode(gc)) {
2848 textContent += gc.text;
2849 } else if (isElementNode(gc)) {
2850 const gcTag = gc.tag.toUpperCase();
2851 if (["H1", "H2", "H3", "H4", "H5", "H6", "P"].includes(gcTag)) {
2852 textContent += this.getTextContent(gc);
2853 }
2854 }
2855 }
2856
2857 const titleText = textContent.trim();
2858 const headingNode: HeadingElement = {
2859 type: "h4",
2860 children: [
2861 {
2862 text: titleText,
2863 ...(this.shouldHaveGeneratingMark(titleText)
2864 ? { generating: true }
2865 : {}),
2866 } as TText,
2867 ],
2868 } as HeadingElement;
2869
2870 const slopeItem: TSlopeItemElement = {
2871 type: SLOPE_ITEM,
2872 ...child.attributes,
2873 ...(icon ? { icon } : {}),
2874 children: [headingNode],
2875 };
2876 slopeItems.push(slopeItem);
2877 }
2878 }
2879
2880 return {
2881 type: SLOPE_GROUP,
2882 ...node.attributes,
2883 children: slopeItems,
2884 } as TSlopeGroupElement;
2885 }
2886
2887 /**
2888 * Create a connected circles layout element
2889 */
2890 private createConnectedCircles(node: XMLNode): TConnectedCirclesGroupElement {
2891 const connectedCirclesItems: TConnectedCirclesItemElement[] = [];
2892
2893 for (const child of node.children) {
2894 if (isElementNode(child) && child.tag.toUpperCase() === "DIV") {
2895 const itemChildren = this.processNodes(child.children) as Descendant[];
2896 const connectedCirclesItem: TConnectedCirclesItemElement = {
2897 type: CONNECTED_CIRCLES_ITEM,
2898 ...child.attributes,
2899 children:
2900 itemChildren.length > 0 ? itemChildren : [{ text: "" } as TText],
2901 };
2902 connectedCirclesItems.push(connectedCirclesItem);
2903 }
2904 }
2905
2906 return {
2907 type: CONNECTED_CIRCLES_GROUP,
2908 ...node.attributes,
2909 children: connectedCirclesItems,
2910 } as TConnectedCirclesGroupElement;
2911 }
2912
2913 /**
2914 * Create a circular grid layout element
2915 */
2916 private createCircularGrid(node: XMLNode): TCircularGridGroupElement {
2917 const circularGridItems: TCircularGridItemElement[] = [];
2918
2919 for (const child of node.children) {
2920 if (isElementNode(child) && child.tag.toUpperCase() === "DIV") {
2921 const itemChildren = this.processNodes(child.children) as Descendant[];
2922 const circularGridItem: TCircularGridItemElement = {
2923 type: CIRCULAR_GRID_ITEM,
2924 ...child.attributes,
2925 children:
2926 itemChildren.length > 0 ? itemChildren : [{ text: "" } as TText],
2927 };
2928 circularGridItems.push(circularGridItem);
2929 }
2930 }
2931
2932 const centerText =
2933 node.attributes.centertext ||
2934 node.attributes.centerText ||
2935 "Smart Diagram";
2936
2937 return {
2938 type: CIRCULAR_GRID_GROUP,
2939 centerText,
2940 ...node.attributes,
2941 children: circularGridItems,
2942 } as TCircularGridGroupElement;
2943 }
2944
2945 /**
2946 * Create a snake layout element
2947 */
2948 private createSnake(node: XMLNode): TSnakeGroupElement {
2949 const snakeItems: TSnakeItemElement[] = [];
2950
2951 for (const child of node.children) {
2952 if (isElementNode(child) && child.tag.toUpperCase() === "DIV") {
2953 const itemChildren = this.processNodes(child.children) as Descendant[];
2954 const snakeItem: TSnakeItemElement = {
2955 type: SNAKE_ITEM,
2956 ...child.attributes,
2957 children:
2958 itemChildren.length > 0 ? itemChildren : [{ text: "" } as TText],
2959 };
2960 snakeItems.push(snakeItem);
2961 }
2962 }
2963
2964 return {
2965 type: SNAKE_GROUP,
2966 ...node.attributes,
2967 children: snakeItems,
2968 } as TSnakeGroupElement;
2969 }
2970 }
2971
2972 // Example usage
2973 export function parseSlideXml(xmlData: string): PlateSlide[] {
2974 const parser = new SlideParser();
2975 parser.parseChunk(xmlData);
2976 parser.finalize();
2977 return parser.getAllSlides();
2978 }
2979
2979 lines TYPESCRIPT