@@ -39,45 +39,149 @@ public static function hasFormattingEntities(array $entities): bool
3939
4040 /**
4141 * Convert message to MarkdownV2 format.
42+ * Supports nested and overlapping entities via event-based processing.
4243 *
4344 * @param string $text
4445 * @param array $entities
4546 * @return string
4647 */
4748 public static function conversionMarkdownFormat (string $ text , array $ entities ): string
4849 {
49- usort ($ entities , fn ($ a , $ b ) => $ b ['offset ' ] <=> $ a ['offset ' ]);
50+ $ entities = array_values (array_filter (
51+ $ entities ,
52+ fn ($ e ) => in_array ($ e ['type ' ], self ::FORMATTING_ENTITY_TYPES , true )
53+ ));
5054
51- foreach ($ entities as $ entity ) {
52- $ offset = $ entity ['offset ' ];
53- $ length = $ entity ['length ' ];
54- $ type = $ entity ['type ' ];
55- $ part = mb_substr ($ text , $ offset , $ length );
56-
57- switch ($ type ) {
58- case 'bold ' :
59- $ wrapped = "** {$ part }** " ;
60- break ;
61- case 'italic ' :
62- $ wrapped = "_ {$ part }_ " ;
63- break ;
64- case 'code ' :
65- $ wrapped = "` {$ part }` " ;
66- break ;
67- case 'pre ' :
68- $ wrapped = "``` \n{$ part }\n``` " ;
69- break ;
70- case 'text_link ' :
71- $ wrapped = "[ {$ part }]( {$ entity ['url ' ]}) " ;
72- break ;
73- default :
74- $ wrapped = $ part ;
55+ if (empty ($ entities )) {
56+ return self ::escapeMarkdownV2 ($ text );
57+ }
58+
59+ $ events = [];
60+ foreach ($ entities as $ idx => $ entity ) {
61+ $ events [] = [$ entity ['offset ' ], 'open ' , $ idx ];
62+ $ events [] = [$ entity ['offset ' ] + $ entity ['length ' ], 'close ' , $ idx ];
63+ }
64+
65+ usort ($ events , function ($ a , $ b ) use ($ entities ) {
66+ if ($ a [0 ] !== $ b [0 ]) {
67+ return $ a [0 ] - $ b [0 ];
68+ }
69+ // At same position: closes before opens
70+ if ($ a [1 ] !== $ b [1 ]) {
71+ return $ a [1 ] === 'close ' ? -1 : 1 ;
72+ }
73+ // Same type at same position
74+ if ($ a [1 ] === 'open ' ) {
75+ // Longer (outer) entities open first
76+ return $ entities [$ b [2 ]]['length ' ] - $ entities [$ a [2 ]]['length ' ];
7577 }
78+ // Shorter (inner) entities close first
79+ return $ entities [$ a [2 ]]['length ' ] - $ entities [$ b [2 ]]['length ' ];
80+ });
7681
77- $ text = mb_substr ($ text , 0 , $ offset ) . $ wrapped . mb_substr ($ text , $ offset + $ length );
82+ $ result = '' ;
83+ $ lastPos = 0 ;
84+ $ openEntities = [];
85+
86+ foreach ($ events as [$ pos , $ type , $ entityIdx ]) {
87+ $ entity = $ entities [$ entityIdx ];
88+
89+ if ($ pos > $ lastPos ) {
90+ $ segment = mb_substr ($ text , $ lastPos , $ pos - $ lastPos );
91+ $ insideCode = !empty (array_filter ($ openEntities , fn ($ e ) => in_array ($ e ['type ' ], ['code ' , 'pre ' ])));
92+ $ result .= $ insideCode ? self ::escapeCode ($ segment ) : self ::escapeMarkdownV2 ($ segment );
93+ $ lastPos = $ pos ;
94+ }
95+
96+ if ($ type === 'open ' ) {
97+ $ result .= self ::getOpenMarker ($ entity );
98+ $ openEntities [$ entityIdx ] = $ entity ;
99+ } else {
100+ $ result .= self ::getCloseMarker ($ entity );
101+ unset($ openEntities [$ entityIdx ]);
102+ }
78103 }
79104
80- return $ text ;
105+ if ($ lastPos < mb_strlen ($ text )) {
106+ $ result .= self ::escapeMarkdownV2 (mb_substr ($ text , $ lastPos ));
107+ }
108+
109+ return $ result ;
110+ }
111+
112+ /**
113+ * Get the opening MarkdownV2 marker for an entity.
114+ *
115+ * @param array $entity
116+ * @return string
117+ */
118+ private static function getOpenMarker (array $ entity ): string
119+ {
120+ return match ($ entity ['type ' ]) {
121+ 'bold ' => '* ' ,
122+ 'italic ' => '_ ' ,
123+ 'underline ' => '__ ' ,
124+ 'strikethrough ' => '~ ' ,
125+ 'spoiler ' => '|| ' ,
126+ 'code ' => '` ' ,
127+ 'pre ' => '``` ' . ($ entity ['language ' ] ?? '' ) . "\n" ,
128+ 'text_link ' => '[ ' ,
129+ 'blockquote ' => '> ' ,
130+ default => '' ,
131+ };
132+ }
133+
134+ /**
135+ * Get the closing MarkdownV2 marker for an entity.
136+ *
137+ * @param array $entity
138+ * @return string
139+ */
140+ private static function getCloseMarker (array $ entity ): string
141+ {
142+ return match ($ entity ['type ' ]) {
143+ 'bold ' => '* ' ,
144+ 'italic ' => '_ ' ,
145+ 'underline ' => '__ ' ,
146+ 'strikethrough ' => '~ ' ,
147+ 'spoiler ' => '|| ' ,
148+ 'code ' => '` ' ,
149+ 'pre ' => "\n``` " ,
150+ 'text_link ' => ']( ' . self ::escapeUrl ($ entity ['url ' ] ?? '' ) . ') ' ,
151+ default => '' ,
152+ };
153+ }
154+
155+ /**
156+ * Escape special characters for MarkdownV2.
157+ *
158+ * @param string $text
159+ * @return string
160+ */
161+ private static function escapeMarkdownV2 (string $ text ): string
162+ {
163+ return preg_replace ('/([_*\[\]()~`>#+\-=|{}.! \\\\])/ ' , '\\\\$1 ' , $ text ) ?? $ text ;
81164 }
82165
166+ /**
167+ * Escape characters inside code/pre blocks.
168+ *
169+ * @param string $text
170+ * @return string
171+ */
172+ private static function escapeCode (string $ text ): string
173+ {
174+ return str_replace (['\\' , '` ' ], ['\\\\' , '\\` ' ], $ text );
175+ }
176+
177+ /**
178+ * Escape characters inside URL part of text_link.
179+ *
180+ * @param string $url
181+ * @return string
182+ */
183+ private static function escapeUrl (string $ url ): string
184+ {
185+ return str_replace (['\\' , ') ' ], ['\\\\' , '\\) ' ], $ url );
186+ }
83187}
0 commit comments