@@ -21,16 +21,15 @@ function processCloseSpanMarkers(elements: Element[]): Element[] {
2121 if ( ! elem ) continue ;
2222
2323 // Check for closeSpan marker
24+ // _closeSpan is on data directly, not data.attributes
2425 if (
2526 elem . element === "container" &&
2627 elem . data &&
2728 typeof elem . data === "object" &&
2829 "type" in elem . data &&
2930 elem . data . type === "span" &&
30- "attributes" in elem . data &&
31- typeof elem . data . attributes === "object" &&
32- elem . data . attributes &&
33- "_closeSpan" in elem . data . attributes
31+ "_closeSpan" in elem . data &&
32+ ( elem . data as any ) . _closeSpan === true
3433 ) {
3534 // Wrap all preceding content in a span
3635 if ( result . length > 0 ) {
@@ -80,6 +79,50 @@ export const paragraphRule: BlockRule = {
8079 // Process closeSpan markers (for split spans)
8180 let elements = processCloseSpanMarkers ( result . elements ) ;
8281
82+ // Split paragraph at aligned images (they become block-level elements)
83+ // This also removes float center images (invalid in Wikidot)
84+ const splitResult = splitAtAlignedImages ( elements ) ;
85+ const hasAlignedImages = splitResult . some ( ( part ) => part . type === "image" ) ;
86+
87+ if ( splitResult . length > 1 || hasAlignedImages ) {
88+ // Return multiple elements: paragraphs and standalone images
89+ const outputElements : Element [ ] = [ ] ;
90+ for ( const part of splitResult ) {
91+ if ( part . type === "image" ) {
92+ outputElements . push ( part . element ) ;
93+ } else if ( part . elements . length > 0 ) {
94+ // Clean up paragraph elements
95+ const cleaned = cleanParagraphElements ( part . elements ) ;
96+ if ( cleaned . length > 0 ) {
97+ outputElements . push ( {
98+ element : "container" ,
99+ data : {
100+ type : "paragraph" ,
101+ attributes : { } ,
102+ elements : cleaned ,
103+ } ,
104+ } ) ;
105+ }
106+ }
107+ }
108+ if ( outputElements . length === 0 ) {
109+ return { success : false } ;
110+ }
111+ return {
112+ success : true ,
113+ elements : outputElements ,
114+ consumed : result . consumed ,
115+ } ;
116+ }
117+
118+ // Rebuild elements from splitResult (may have float center removed)
119+ elements = [ ] ;
120+ for ( const part of splitResult ) {
121+ if ( part . type === "text" ) {
122+ elements . push ( ...part . elements ) ;
123+ }
124+ }
125+
83126 // Remove trailing line-breaks (they shouldn't appear at end of paragraph)
84127 // Exception: line-breaks flagged by preserveTrailingLineBreak context are kept
85128 while ( elements . length > 0 && elements [ elements . length - 1 ] ?. element === "line-break" ) {
@@ -155,3 +198,95 @@ function parseInlineContent(ctx: ParseContext): {
155198 // The parser will stop at double NEWLINE (paragraph break)
156199 return parseInlineUntil ( ctx , "PARAGRAPH_BREAK" as any ) ;
157200}
201+
202+ type SplitPart = { type : "text" ; elements : Element [ ] } | { type : "image" ; element : Element } ;
203+
204+ /**
205+ * Split elements at aligned images
206+ * Aligned images become block-level elements, splitting the paragraph
207+ * Float center images are removed entirely (invalid in Wikidot)
208+ */
209+ function splitAtAlignedImages ( elements : Element [ ] ) : SplitPart [ ] {
210+ const parts : SplitPart [ ] = [ ] ;
211+ let currentText : Element [ ] = [ ] ;
212+
213+ for ( let i = 0 ; i < elements . length ; i ++ ) {
214+ const elem = elements [ i ] ;
215+ if ( ! elem ) continue ;
216+
217+ if ( isAlignedImage ( elem ) ) {
218+ const imageData = ( elem as any ) . data ;
219+ // Float center is invalid - skip the image AND preceding text on same line
220+ if ( imageData ?. alignment ?. float && imageData . alignment . align === "center" ) {
221+ // Remove text preceding the image on the same line (back to last line-break)
222+ while ( currentText . length > 0 ) {
223+ const last = currentText [ currentText . length - 1 ] ;
224+ if ( last ?. element === "line-break" ) {
225+ break ;
226+ }
227+ currentText . pop ( ) ;
228+ }
229+ // Also skip line-break after the image if present
230+ if ( elements [ i + 1 ] ?. element === "line-break" ) {
231+ i ++ ;
232+ }
233+ continue ;
234+ }
235+
236+ // Save current text as a part
237+ if ( currentText . length > 0 ) {
238+ parts . push ( { type : "text" , elements : [ ...currentText ] } ) ;
239+ currentText = [ ] ;
240+ }
241+ // Add image as standalone element
242+ parts . push ( { type : "image" , element : elem } ) ;
243+ } else {
244+ currentText . push ( elem ) ;
245+ }
246+ }
247+
248+ // Add remaining text
249+ if ( currentText . length > 0 ) {
250+ parts . push ( { type : "text" , elements : currentText } ) ;
251+ }
252+
253+ return parts ;
254+ }
255+
256+ /**
257+ * Check if element is an aligned image (has alignment property)
258+ */
259+ function isAlignedImage ( elem : Element ) : boolean {
260+ if ( elem . element !== "image" ) return false ;
261+ const data = ( elem as any ) . data ;
262+ return data ?. alignment != null ;
263+ }
264+
265+ /**
266+ * Clean up paragraph elements (remove trailing/leading line-breaks)
267+ */
268+ function cleanParagraphElements ( elements : Element [ ] ) : Element [ ] {
269+ let result = [ ...elements ] ;
270+
271+ // Remove trailing line-breaks
272+ while ( result . length > 0 && result [ result . length - 1 ] ?. element === "line-break" ) {
273+ result . pop ( ) ;
274+ }
275+
276+ // Remove trailing whitespace
277+ while ( result . length > 0 ) {
278+ const last = result [ result . length - 1 ] ;
279+ if ( last ?. element === "text" && typeof last . data === "string" && last . data . trim ( ) === "" ) {
280+ result . pop ( ) ;
281+ } else {
282+ break ;
283+ }
284+ }
285+
286+ // Remove leading line-breaks
287+ while ( result . length > 0 && result [ 0 ] ?. element === "line-break" ) {
288+ result . shift ( ) ;
289+ }
290+
291+ return result ;
292+ }
0 commit comments