line-markdown-parser 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- line_markdown_parser/__init__.py +20 -0
- line_markdown_parser/_renderer.py +1565 -0
- line_markdown_parser/converter.py +260 -0
- line_markdown_parser/py.typed +0 -0
- line_markdown_parser-0.1.2.dist-info/METADATA +97 -0
- line_markdown_parser-0.1.2.dist-info/RECORD +9 -0
- line_markdown_parser-0.1.2.dist-info/WHEEL +5 -0
- line_markdown_parser-0.1.2.dist-info/licenses/LICENSE +21 -0
- line_markdown_parser-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1565 @@
|
|
|
1
|
+
"""Pure Markdown-to-Flex component rendering without I/O or logging."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import urllib.parse
|
|
5
|
+
from bisect import bisect_left, bisect_right
|
|
6
|
+
from collections.abc import Callable, Iterator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
LINE_ACTION_URI_MAX_LENGTH = 1000
|
|
11
|
+
BODY_TEXT_COLOR = "#696969"
|
|
12
|
+
STRONG_TEXT_COLOR = "#555555"
|
|
13
|
+
LINK_TEXT_COLOR = "#0066FF"
|
|
14
|
+
INLINE_CODE_COLOR = "#C46A00"
|
|
15
|
+
MUTED_TEXT_COLOR = "#888888"
|
|
16
|
+
CODE_BLOCK_BACKGROUND = "#F5F5F5"
|
|
17
|
+
CODE_BLOCK_INDENT_SPACE = "\xa0"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_char_emoji(char):
|
|
21
|
+
"""文字が絵文字の主要なUnicode範囲に含まれるか判定する。"""
|
|
22
|
+
if not char:
|
|
23
|
+
return False
|
|
24
|
+
code = ord(char)
|
|
25
|
+
return (
|
|
26
|
+
127744 <= code <= 128511
|
|
27
|
+
or 128512 <= code <= 128591
|
|
28
|
+
or 128640 <= code <= 128767
|
|
29
|
+
or (129280 <= code <= 129535)
|
|
30
|
+
or (9728 <= code <= 9983)
|
|
31
|
+
or (9984 <= code <= 10175)
|
|
32
|
+
or (129648 <= code <= 129791)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_ESCAPABLE_INLINE_CHARS = set("\\`*_{}[]()#+-.!<>~")
|
|
37
|
+
_INLINE_DELIMITERS: tuple[tuple[str, dict[str, bool]], ...] = (
|
|
38
|
+
("***", {"bold": True, "italic": True}),
|
|
39
|
+
("___", {"bold": True, "italic": True}),
|
|
40
|
+
("**", {"bold": True}),
|
|
41
|
+
("__", {"bold": True}),
|
|
42
|
+
("~~", {"strike": True}),
|
|
43
|
+
("*", {"italic": True}),
|
|
44
|
+
("_", {"italic": True}),
|
|
45
|
+
)
|
|
46
|
+
_CORE_URL_PATTERN = "https?:\\/\\/[\\w\\-\\.~:\\/?#[\\]@!$&\\'\\(\\)\\*\\+,;=%.]+"
|
|
47
|
+
_MARKDOWN_DESTINATION_PATTERN = "(?:<[^>\\n]+>|(?:[^\\s()<>]|\\([^\\s()<>]*\\))+)"
|
|
48
|
+
_MARKDOWN_DESTINATION_RE = re.compile(
|
|
49
|
+
f"\\(\\s*(?P<url>{_MARKDOWN_DESTINATION_PATTERN})\\s*\\)"
|
|
50
|
+
)
|
|
51
|
+
_URL_LINK_PATTERN = re.compile(
|
|
52
|
+
f"(?P<autolink><(?P<auto_url>{_CORE_URL_PATTERN})>)|(?P<plain_url>{_CORE_URL_PATTERN})"
|
|
53
|
+
)
|
|
54
|
+
_URL_START_PATTERN = re.compile(r"<?https?://")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class _InlineLink:
|
|
59
|
+
start: int
|
|
60
|
+
end: int
|
|
61
|
+
kind: str
|
|
62
|
+
text: str
|
|
63
|
+
url: str
|
|
64
|
+
label: str | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _iter_links(text: str) -> Iterator[_InlineLink]:
|
|
68
|
+
"""ラベルの括弧を一度だけ走査し、コードとエスケープを除外してリンクを返す。"""
|
|
69
|
+
code_ends: dict[int, int] = {}
|
|
70
|
+
offset = 0
|
|
71
|
+
for line in text.split("\n"):
|
|
72
|
+
code_ends.update(
|
|
73
|
+
(offset + start, offset + end)
|
|
74
|
+
for start, end, _ in _iter_inline_code_spans(line)
|
|
75
|
+
)
|
|
76
|
+
offset += len(line) + 1
|
|
77
|
+
links: list[_InlineLink] = []
|
|
78
|
+
opening: int | None = None
|
|
79
|
+
index = 0
|
|
80
|
+
while index < len(text):
|
|
81
|
+
if index in code_ends:
|
|
82
|
+
index = code_ends[index]
|
|
83
|
+
continue
|
|
84
|
+
if text[index] == "\\" and index + 1 < len(text):
|
|
85
|
+
index += 2
|
|
86
|
+
continue
|
|
87
|
+
if text[index] == "[":
|
|
88
|
+
opening = index
|
|
89
|
+
elif text[index] == "]":
|
|
90
|
+
if opening is not None:
|
|
91
|
+
destination = _MARKDOWN_DESTINATION_RE.match(text, index + 1)
|
|
92
|
+
if destination:
|
|
93
|
+
end = destination.end()
|
|
94
|
+
links.append(
|
|
95
|
+
_InlineLink(
|
|
96
|
+
opening,
|
|
97
|
+
end,
|
|
98
|
+
"markdown",
|
|
99
|
+
text[opening:end],
|
|
100
|
+
destination.group("url"),
|
|
101
|
+
text[opening + 1 : index],
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
index = end - 1
|
|
105
|
+
opening = None
|
|
106
|
+
index += 1
|
|
107
|
+
index = 0
|
|
108
|
+
cursor = 0
|
|
109
|
+
while cursor < len(text):
|
|
110
|
+
while index < len(links) and links[index].start < cursor:
|
|
111
|
+
index += 1
|
|
112
|
+
link = links[index] if index < len(links) else None
|
|
113
|
+
# Markdownの手前でURLの開始だけを探し、宛先内のURLを再走査しない。
|
|
114
|
+
boundary = link.start if link is not None else len(text)
|
|
115
|
+
candidate = _URL_START_PATTERN.search(text, cursor, boundary)
|
|
116
|
+
if candidate is None and link is not None:
|
|
117
|
+
yield link
|
|
118
|
+
cursor = link.end
|
|
119
|
+
index += 1
|
|
120
|
+
elif candidate is not None:
|
|
121
|
+
match = _URL_LINK_PATTERN.match(text, candidate.start())
|
|
122
|
+
if match is None:
|
|
123
|
+
cursor = candidate.start() + 1
|
|
124
|
+
continue
|
|
125
|
+
autolink = match.group("autolink") is not None
|
|
126
|
+
yield _InlineLink(
|
|
127
|
+
match.start(),
|
|
128
|
+
match.end(),
|
|
129
|
+
"autolink" if autolink else "plain",
|
|
130
|
+
match.group(),
|
|
131
|
+
match.group("auto_url" if autolink else "plain_url"),
|
|
132
|
+
)
|
|
133
|
+
cursor = match.end()
|
|
134
|
+
else:
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _transform_outside_inline_code(
|
|
139
|
+
text: str, transform: Callable[[str], str], *, normalize_html_code: bool = False
|
|
140
|
+
) -> str:
|
|
141
|
+
"""コードを一時的に保護し、その外側だけに文字列置換を適用する。"""
|
|
142
|
+
ranges = [
|
|
143
|
+
(start, end, text[start:end]) for start, end, _ in _iter_inline_code_spans(text)
|
|
144
|
+
]
|
|
145
|
+
code_starts = [start for start, _, _ in ranges]
|
|
146
|
+
|
|
147
|
+
def code_end_at(position: int, minimum_start: int) -> int | None:
|
|
148
|
+
index = bisect_right(code_starts, position) - 1
|
|
149
|
+
# 先に処理したHTML code内のバッククォートは、後続のタグを保護しない。
|
|
150
|
+
if (
|
|
151
|
+
index >= 0
|
|
152
|
+
and ranges[index][0] >= minimum_start
|
|
153
|
+
and position < ranges[index][1]
|
|
154
|
+
):
|
|
155
|
+
return ranges[index][1]
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
opening_pattern = re.compile(r"<code\b", re.IGNORECASE)
|
|
159
|
+
closing_pattern = re.compile(r"</code>", re.IGNORECASE)
|
|
160
|
+
cursor = 0
|
|
161
|
+
while opening := opening_pattern.search(text, cursor):
|
|
162
|
+
if code_end := code_end_at(opening.start(), cursor):
|
|
163
|
+
cursor = code_end
|
|
164
|
+
continue
|
|
165
|
+
tag_end = text.find(">", opening.end())
|
|
166
|
+
if tag_end == -1:
|
|
167
|
+
break
|
|
168
|
+
closing = closing_pattern.search(text, tag_end + 1)
|
|
169
|
+
if closing is None:
|
|
170
|
+
break
|
|
171
|
+
replacement = text[opening.start() : closing.end()]
|
|
172
|
+
if normalize_html_code:
|
|
173
|
+
body = text[tag_end + 1 : closing.start()]
|
|
174
|
+
delimiter = "`" * (
|
|
175
|
+
max((len(run) for run in re.findall(r"`+", body)), default=0) + 1
|
|
176
|
+
)
|
|
177
|
+
replacement = f"{delimiter} {body} {delimiter}"
|
|
178
|
+
ranges.append((opening.start(), closing.end(), replacement))
|
|
179
|
+
cursor = closing.end()
|
|
180
|
+
if not ranges:
|
|
181
|
+
return transform(text)
|
|
182
|
+
|
|
183
|
+
prefix = "\x00code:"
|
|
184
|
+
while prefix in text:
|
|
185
|
+
prefix += ":"
|
|
186
|
+
fragments = []
|
|
187
|
+
protected = []
|
|
188
|
+
cursor = 0
|
|
189
|
+
for start, end, replacement in sorted(ranges):
|
|
190
|
+
if start < cursor:
|
|
191
|
+
continue
|
|
192
|
+
fragments.extend((text[cursor:start], f"{prefix}{len(protected)}\x00"))
|
|
193
|
+
protected.append(replacement)
|
|
194
|
+
cursor = end
|
|
195
|
+
fragments.append(text[cursor:])
|
|
196
|
+
transformed = transform("".join(fragments))
|
|
197
|
+
return re.sub(
|
|
198
|
+
re.escape(prefix) + r"(\d+)\x00",
|
|
199
|
+
lambda match: protected[int(match.group(1))],
|
|
200
|
+
transformed,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _normalize_inline_html_lite(text: str) -> str:
|
|
205
|
+
"""対応するインラインHTMLタグをMarkdown記法に置き換える。"""
|
|
206
|
+
if not text or "<" not in text:
|
|
207
|
+
return text
|
|
208
|
+
tag_replacements: tuple[tuple[str, str, str], ...] = (
|
|
209
|
+
("strong", "**", "**"),
|
|
210
|
+
("b", "**", "**"),
|
|
211
|
+
("em", "*", "*"),
|
|
212
|
+
("i", "*", "*"),
|
|
213
|
+
("s", "~~", "~~"),
|
|
214
|
+
("del", "~~", "~~"),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def replace_tags(source: str) -> str:
|
|
218
|
+
for tag, opener, closer in tag_replacements:
|
|
219
|
+
pattern = f"<{tag}\\b[^>]*>([\\s\\S]*?)</{tag}>"
|
|
220
|
+
source = re.sub(
|
|
221
|
+
pattern,
|
|
222
|
+
lambda match, prefix=opener, suffix=closer: (
|
|
223
|
+
f"{prefix}{match.group(1)}{suffix}"
|
|
224
|
+
),
|
|
225
|
+
source,
|
|
226
|
+
flags=re.IGNORECASE,
|
|
227
|
+
)
|
|
228
|
+
return source
|
|
229
|
+
|
|
230
|
+
return _transform_outside_inline_code(text, replace_tags, normalize_html_code=True)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _is_escaped(text: str, index: int) -> bool:
|
|
234
|
+
"""index位置の文字が、直前の奇数個のバックスラッシュでエスケープされているか判定する。"""
|
|
235
|
+
slash_count = 0
|
|
236
|
+
cursor = index - 1
|
|
237
|
+
while cursor >= 0 and text[cursor] == "\\":
|
|
238
|
+
slash_count += 1
|
|
239
|
+
cursor -= 1
|
|
240
|
+
return slash_count % 2 == 1
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _can_open_inline_delimiter(text: str, index: int, delimiter: str) -> bool:
|
|
244
|
+
after_index = index + len(delimiter)
|
|
245
|
+
if after_index >= len(text) or text[after_index].isspace():
|
|
246
|
+
return False
|
|
247
|
+
if _is_escaped(text, index):
|
|
248
|
+
return False
|
|
249
|
+
if delimiter.startswith("_"):
|
|
250
|
+
before = text[index - 1] if index > 0 else ""
|
|
251
|
+
after = text[after_index]
|
|
252
|
+
if before.isalnum() and after.isalnum():
|
|
253
|
+
return False
|
|
254
|
+
return True
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _can_close_inline_delimiter(text: str, index: int, delimiter: str) -> bool:
|
|
258
|
+
before = text[index - 1] if index > 0 else ""
|
|
259
|
+
if not before or before.isspace():
|
|
260
|
+
return False
|
|
261
|
+
if _is_escaped(text, index):
|
|
262
|
+
return False
|
|
263
|
+
if delimiter.startswith("_"):
|
|
264
|
+
after_index = index + len(delimiter)
|
|
265
|
+
after = text[after_index] if after_index < len(text) else ""
|
|
266
|
+
if before.isalnum() and after.isalnum():
|
|
267
|
+
return False
|
|
268
|
+
return True
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _find_closing_inline_delimiter(
|
|
272
|
+
text: str,
|
|
273
|
+
start: int,
|
|
274
|
+
delimiter: str,
|
|
275
|
+
code_ranges: list[tuple[int, int]] | None = None,
|
|
276
|
+
*,
|
|
277
|
+
cache: dict[str, list[int]] | None = None,
|
|
278
|
+
) -> int | None:
|
|
279
|
+
"""同じ本文・保護範囲の閉じ記号を共有し、未閉じ記号ごとの再探索を避ける。"""
|
|
280
|
+
if cache is None:
|
|
281
|
+
cache = {}
|
|
282
|
+
if delimiter not in cache:
|
|
283
|
+
positions: list[int] = []
|
|
284
|
+
protected = sorted(code_ranges or [])
|
|
285
|
+
range_index = 0
|
|
286
|
+
cursor = 0
|
|
287
|
+
while cursor < len(text):
|
|
288
|
+
found_at = text.find(delimiter, cursor)
|
|
289
|
+
if found_at == -1:
|
|
290
|
+
break
|
|
291
|
+
while (
|
|
292
|
+
range_index < len(protected) and protected[range_index][1] <= found_at
|
|
293
|
+
):
|
|
294
|
+
range_index += 1
|
|
295
|
+
inside_protected = (
|
|
296
|
+
range_index < len(protected) and protected[range_index][0] <= found_at
|
|
297
|
+
)
|
|
298
|
+
if not inside_protected and _can_close_inline_delimiter(
|
|
299
|
+
text, found_at, delimiter
|
|
300
|
+
):
|
|
301
|
+
positions.append(found_at)
|
|
302
|
+
cursor = found_at + 1
|
|
303
|
+
cache[delimiter] = positions
|
|
304
|
+
positions = cache[delimiter]
|
|
305
|
+
position_index = bisect_left(positions, start)
|
|
306
|
+
return positions[position_index] if position_index < len(positions) else None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _span_for_text(text: str, style: dict[str, bool]) -> dict | None:
|
|
310
|
+
if text == "":
|
|
311
|
+
return None
|
|
312
|
+
span = {"type": "span", "text": text, "color": BODY_TEXT_COLOR, "size": "sm"}
|
|
313
|
+
if style.get("bold"):
|
|
314
|
+
span["weight"] = "bold"
|
|
315
|
+
span["color"] = STRONG_TEXT_COLOR
|
|
316
|
+
if style.get("italic"):
|
|
317
|
+
span["style"] = "italic"
|
|
318
|
+
if style.get("strike"):
|
|
319
|
+
span["decoration"] = "line-through"
|
|
320
|
+
if style.get("code"):
|
|
321
|
+
span["color"] = INLINE_CODE_COLOR
|
|
322
|
+
return span
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _merge_adjacent_spans(spans: list[dict]) -> list[dict]:
|
|
326
|
+
merged: list[dict] = []
|
|
327
|
+
for span in spans:
|
|
328
|
+
if not span or not span.get("text"):
|
|
329
|
+
continue
|
|
330
|
+
if merged:
|
|
331
|
+
previous = merged[-1]
|
|
332
|
+
previous_key = {k: v for k, v in previous.items() if k != "text"}
|
|
333
|
+
current_key = {k: v for k, v in span.items() if k != "text"}
|
|
334
|
+
if previous_key == current_key:
|
|
335
|
+
previous["text"] += span["text"]
|
|
336
|
+
continue
|
|
337
|
+
merged.append(span)
|
|
338
|
+
return merged
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _iter_inline_code_spans(text: str) -> list[tuple[int, int, str]]:
|
|
342
|
+
"""インラインコードを (開始位置, 終了位置, 正規化した本文) のタプルで列挙する。
|
|
343
|
+
|
|
344
|
+
終了位置は閉じるバッククォートの直後。本文の改行と連続する空白を半角スペース1つにまとめる。
|
|
345
|
+
強調の解析でもこの範囲を使い、**`code`** のような記法を処理する。
|
|
346
|
+
"""
|
|
347
|
+
spans: list[tuple[int, int, str]] = []
|
|
348
|
+
index = 0
|
|
349
|
+
while index < len(text):
|
|
350
|
+
if text[index] != "`" or _is_escaped(text, index):
|
|
351
|
+
index += 1
|
|
352
|
+
continue
|
|
353
|
+
run_end = index
|
|
354
|
+
while run_end < len(text) and text[run_end] == "`":
|
|
355
|
+
run_end += 1
|
|
356
|
+
delimiter = text[index:run_end]
|
|
357
|
+
closing = run_end
|
|
358
|
+
closing_index: int | None = None
|
|
359
|
+
while closing < len(text):
|
|
360
|
+
candidate = text.find(delimiter, closing)
|
|
361
|
+
if candidate == -1:
|
|
362
|
+
break
|
|
363
|
+
if not _is_escaped(text, candidate):
|
|
364
|
+
closing_index = candidate
|
|
365
|
+
break
|
|
366
|
+
closing = candidate + 1
|
|
367
|
+
if closing_index is None:
|
|
368
|
+
index = run_end
|
|
369
|
+
continue
|
|
370
|
+
code_text = text[run_end:closing_index]
|
|
371
|
+
code_text = re.sub("\\s+", " ", code_text.replace("\n", " ")).strip(" ")
|
|
372
|
+
end = closing_index + len(delimiter)
|
|
373
|
+
spans.append((index, end, code_text))
|
|
374
|
+
index = end
|
|
375
|
+
return spans
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _inline_code_span_ranges(text: str) -> list[tuple[int, int]]:
|
|
379
|
+
"""インラインコードの範囲を、バッククォートも含めて返す。"""
|
|
380
|
+
return [(start, end) for start, end, _ in _iter_inline_code_spans(text)]
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _position_inside_ranges(position: int, ranges: list[tuple[int, int]]) -> bool:
|
|
384
|
+
"""開始位置順で重複のない範囲から、指定位置を二分探索する。"""
|
|
385
|
+
index = bisect_right(ranges, position, key=lambda interval: interval[0]) - 1
|
|
386
|
+
return index >= 0 and position < ranges[index][1]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _find_unclosed_inline_delimiter(text: str) -> tuple[str, int] | None:
|
|
390
|
+
code_ranges = _inline_code_span_ranges(text)
|
|
391
|
+
closing_cache: dict[str, list[int]] = {}
|
|
392
|
+
index = 0
|
|
393
|
+
while index < len(text):
|
|
394
|
+
if _position_inside_ranges(index, code_ranges):
|
|
395
|
+
index += 1
|
|
396
|
+
continue
|
|
397
|
+
matched = False
|
|
398
|
+
for delimiter, _style_update in _INLINE_DELIMITERS:
|
|
399
|
+
if not text.startswith(delimiter, index):
|
|
400
|
+
continue
|
|
401
|
+
if not _can_open_inline_delimiter(text, index, delimiter):
|
|
402
|
+
continue
|
|
403
|
+
inner_start = index + len(delimiter)
|
|
404
|
+
closing_index = _find_closing_inline_delimiter(
|
|
405
|
+
text, inner_start, delimiter, code_ranges, cache=closing_cache
|
|
406
|
+
)
|
|
407
|
+
if closing_index is None:
|
|
408
|
+
return (delimiter, index)
|
|
409
|
+
index = closing_index + len(delimiter)
|
|
410
|
+
matched = True
|
|
411
|
+
break
|
|
412
|
+
if not matched:
|
|
413
|
+
index += 1
|
|
414
|
+
return None
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _inline_link_ranges(
|
|
418
|
+
text: str, code_ranges: list[tuple[int, int]]
|
|
419
|
+
) -> list[tuple[int, int]]:
|
|
420
|
+
"""リンクの範囲を返す。URL内の記号を外側の強調と混同しないために使う。"""
|
|
421
|
+
ranges: list[tuple[int, int]] = []
|
|
422
|
+
prefix = ""
|
|
423
|
+
previous_end = 0
|
|
424
|
+
for link in _iter_links(text):
|
|
425
|
+
start, end = link.start, link.end
|
|
426
|
+
if _position_inside_ranges(start, code_ranges):
|
|
427
|
+
continue
|
|
428
|
+
prefix += text[previous_end:start]
|
|
429
|
+
if link.kind == "plain":
|
|
430
|
+
url = _strip_plain_url_punctuation(link.url)
|
|
431
|
+
opener = _find_unclosed_inline_delimiter(prefix + "x")
|
|
432
|
+
if opener and url.endswith(opener[0]):
|
|
433
|
+
url = url[: -len(opener[0])]
|
|
434
|
+
end = start + len(url)
|
|
435
|
+
ranges.append((start, end))
|
|
436
|
+
prefix += "x"
|
|
437
|
+
previous_end = end
|
|
438
|
+
return ranges
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _strip_plain_url_punctuation(url: str) -> str:
|
|
442
|
+
while url and url[-1] in ".,;:!?)>":
|
|
443
|
+
if (
|
|
444
|
+
url[-1] == ")"
|
|
445
|
+
and "(" in url[:-1]
|
|
446
|
+
and (url[:-1].count("(") >= url.count(")"))
|
|
447
|
+
):
|
|
448
|
+
break
|
|
449
|
+
url = url[:-1]
|
|
450
|
+
return url
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _parse_inline_styled_spans(
|
|
454
|
+
text: str, style: dict[str, bool], *, parse_links: bool = False, _depth: int = 0
|
|
455
|
+
) -> list[dict]:
|
|
456
|
+
"""インラインのMarkdownを解析し、リンクの前後にも同じ強調を適用する。
|
|
457
|
+
|
|
458
|
+
インラインコードを優先して処理し、その範囲を保持して強調の開始・終了記号を対応付ける。
|
|
459
|
+
これにより **`code`** のような記法を扱う。コード内のMarkdownは装飾として解釈しない。
|
|
460
|
+
parse_linksが有効な場合、_linkを内部トークンとして使う。
|
|
461
|
+
アクション行への分離は強調範囲の確定後に行う。
|
|
462
|
+
"""
|
|
463
|
+
if _depth >= 64:
|
|
464
|
+
span = _span_for_text(text, style)
|
|
465
|
+
return [span] if span else []
|
|
466
|
+
spans: list[dict] = []
|
|
467
|
+
buffer: list[str] = []
|
|
468
|
+
index = 0
|
|
469
|
+
code_spans = _iter_inline_code_spans(text)
|
|
470
|
+
code_span_by_start = {
|
|
471
|
+
start: (end, code_text) for start, end, code_text in code_spans
|
|
472
|
+
}
|
|
473
|
+
code_ranges = [(start, end) for start, end, _ in code_spans]
|
|
474
|
+
link_ranges = _inline_link_ranges(text, code_ranges) if parse_links else []
|
|
475
|
+
link_end_by_start = dict(link_ranges)
|
|
476
|
+
protected_ranges = code_ranges + link_ranges
|
|
477
|
+
closing_cache: dict[str, list[int]] = {}
|
|
478
|
+
|
|
479
|
+
def flush_text() -> None:
|
|
480
|
+
if buffer:
|
|
481
|
+
span = _span_for_text("".join(buffer), style)
|
|
482
|
+
if span:
|
|
483
|
+
spans.append(span)
|
|
484
|
+
buffer.clear()
|
|
485
|
+
|
|
486
|
+
while index < len(text):
|
|
487
|
+
char = text[index]
|
|
488
|
+
if (
|
|
489
|
+
char == "\\"
|
|
490
|
+
and index + 1 < len(text)
|
|
491
|
+
and (text[index + 1] in _ESCAPABLE_INLINE_CHARS)
|
|
492
|
+
):
|
|
493
|
+
buffer.append(text[index + 1])
|
|
494
|
+
index += 2
|
|
495
|
+
continue
|
|
496
|
+
if index in code_span_by_start:
|
|
497
|
+
end, code_text = code_span_by_start[index]
|
|
498
|
+
flush_text()
|
|
499
|
+
code_style = dict(style)
|
|
500
|
+
code_style["code"] = True
|
|
501
|
+
span = _span_for_text(code_text, code_style)
|
|
502
|
+
if span:
|
|
503
|
+
spans.append(span)
|
|
504
|
+
index = end
|
|
505
|
+
continue
|
|
506
|
+
if index in link_end_by_start:
|
|
507
|
+
end = link_end_by_start[index]
|
|
508
|
+
flush_text()
|
|
509
|
+
spans.append(
|
|
510
|
+
{"type": "_link", "text": text[index:end], "style": dict(style)}
|
|
511
|
+
)
|
|
512
|
+
index = end
|
|
513
|
+
continue
|
|
514
|
+
matched = False
|
|
515
|
+
for delimiter, style_update in _INLINE_DELIMITERS:
|
|
516
|
+
if not text.startswith(delimiter, index):
|
|
517
|
+
continue
|
|
518
|
+
if not _can_open_inline_delimiter(text, index, delimiter):
|
|
519
|
+
continue
|
|
520
|
+
inner_start = index + len(delimiter)
|
|
521
|
+
closing_index = _find_closing_inline_delimiter(
|
|
522
|
+
text, inner_start, delimiter, protected_ranges, cache=closing_cache
|
|
523
|
+
)
|
|
524
|
+
if closing_index is None:
|
|
525
|
+
continue
|
|
526
|
+
inner_text = text[inner_start:closing_index]
|
|
527
|
+
if not inner_text.strip():
|
|
528
|
+
continue
|
|
529
|
+
flush_text()
|
|
530
|
+
child_style = dict(style)
|
|
531
|
+
child_style.update(style_update)
|
|
532
|
+
spans.extend(
|
|
533
|
+
_parse_inline_styled_spans(
|
|
534
|
+
inner_text, child_style, parse_links=parse_links, _depth=_depth + 1
|
|
535
|
+
)
|
|
536
|
+
)
|
|
537
|
+
index = closing_index + len(delimiter)
|
|
538
|
+
matched = True
|
|
539
|
+
break
|
|
540
|
+
if matched:
|
|
541
|
+
continue
|
|
542
|
+
if char in "*_":
|
|
543
|
+
run_end = index
|
|
544
|
+
while run_end < len(text) and text[run_end] == char:
|
|
545
|
+
run_end += 1
|
|
546
|
+
before = text[index - 1] if index else ""
|
|
547
|
+
after = text[run_end] if run_end < len(text) else ""
|
|
548
|
+
if run_end - index in (2, 3) and (
|
|
549
|
+
not (before.isalnum() and after.isalnum())
|
|
550
|
+
):
|
|
551
|
+
index = run_end
|
|
552
|
+
continue
|
|
553
|
+
buffer.append(char)
|
|
554
|
+
index += 1
|
|
555
|
+
flush_text()
|
|
556
|
+
return spans
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _parse_inline_to_spans(
|
|
560
|
+
text: str, base_style: dict[str, bool] | None = None, *, normalize_html: bool = True
|
|
561
|
+
) -> list[dict]:
|
|
562
|
+
if text == "":
|
|
563
|
+
return []
|
|
564
|
+
style = dict(base_style or {})
|
|
565
|
+
source = _normalize_inline_html_lite(text) if normalize_html else text
|
|
566
|
+
spans = _parse_inline_styled_spans(source, style)
|
|
567
|
+
return _merge_adjacent_spans(spans)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def process_text_segment_to_spans(
|
|
571
|
+
text_segment, base_style: dict[str, bool] | None = None
|
|
572
|
+
):
|
|
573
|
+
"""テキストを、Markdownのインライン装飾を反映したFlex spanに変換する。"""
|
|
574
|
+
return _parse_inline_to_spans(text_segment, base_style)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def process_line_to_flex_components(line):
|
|
578
|
+
"""強調範囲を確定してから、通常テキストと URI アクション行へ分離する。"""
|
|
579
|
+
tokens = _parse_inline_styled_spans(
|
|
580
|
+
_normalize_inline_html_lite(line), {}, parse_links=True
|
|
581
|
+
)
|
|
582
|
+
components: list[dict] = []
|
|
583
|
+
pending_spans: list[dict] = []
|
|
584
|
+
after_link = False
|
|
585
|
+
|
|
586
|
+
def flush_text(*, before_link: bool) -> None:
|
|
587
|
+
spans = _merge_adjacent_spans(pending_spans)
|
|
588
|
+
pending_spans.clear()
|
|
589
|
+
if after_link:
|
|
590
|
+
while spans:
|
|
591
|
+
spans[0]["text"] = spans[0]["text"].lstrip(" \t")
|
|
592
|
+
if spans[0]["text"]:
|
|
593
|
+
break
|
|
594
|
+
spans.pop(0)
|
|
595
|
+
if before_link:
|
|
596
|
+
while spans:
|
|
597
|
+
spans[-1]["text"] = spans[-1]["text"].rstrip(" \t")
|
|
598
|
+
if spans[-1]["text"]:
|
|
599
|
+
break
|
|
600
|
+
spans.pop()
|
|
601
|
+
visible = "".join(span["text"] for span in spans)
|
|
602
|
+
if visible.strip() and (
|
|
603
|
+
not (before_link or after_link)
|
|
604
|
+
or visible.strip("。、,・//|| \t\n")
|
|
605
|
+
or any(span.get("color") == INLINE_CODE_COLOR for span in spans)
|
|
606
|
+
):
|
|
607
|
+
components.append(
|
|
608
|
+
{
|
|
609
|
+
"type": "text",
|
|
610
|
+
"contents": spans,
|
|
611
|
+
"wrap": True,
|
|
612
|
+
"margin": "none",
|
|
613
|
+
"size": "sm",
|
|
614
|
+
}
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
for token in tokens:
|
|
618
|
+
if token["type"] == "_link":
|
|
619
|
+
flush_text(before_link=True)
|
|
620
|
+
components.extend(
|
|
621
|
+
_process_line_to_flex_components_without_code_spans(
|
|
622
|
+
token["text"], token["style"]
|
|
623
|
+
)
|
|
624
|
+
)
|
|
625
|
+
after_link = True
|
|
626
|
+
else:
|
|
627
|
+
pending_spans.append(token)
|
|
628
|
+
flush_text(before_link=False)
|
|
629
|
+
return components
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _clone_text_component_with_spans(
|
|
633
|
+
component: dict[str, Any], spans: list[dict]
|
|
634
|
+
) -> dict[str, Any] | None:
|
|
635
|
+
merged_spans = _merge_adjacent_spans(spans)
|
|
636
|
+
if not merged_spans:
|
|
637
|
+
return None
|
|
638
|
+
cloned = {key: value for key, value in component.items() if key != "contents"}
|
|
639
|
+
cloned["contents"] = merged_spans
|
|
640
|
+
return cloned
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _split_text_component_on_newlines(
|
|
644
|
+
component: dict[str, Any],
|
|
645
|
+
) -> list[dict[str, Any]]:
|
|
646
|
+
if component.get("type") != "text" or not isinstance(
|
|
647
|
+
component.get("contents"), list
|
|
648
|
+
):
|
|
649
|
+
return [component]
|
|
650
|
+
split_components: list[dict[str, Any]] = []
|
|
651
|
+
current_spans: list[dict] = []
|
|
652
|
+
saw_newline = False
|
|
653
|
+
for span in component.get("contents", []):
|
|
654
|
+
span_text = span.get("text")
|
|
655
|
+
if not isinstance(span_text, str) or "\n" not in span_text:
|
|
656
|
+
current_spans.append(span)
|
|
657
|
+
continue
|
|
658
|
+
saw_newline = True
|
|
659
|
+
parts = span_text.split("\n")
|
|
660
|
+
for part_index, part in enumerate(parts):
|
|
661
|
+
if part:
|
|
662
|
+
split_span = dict(span)
|
|
663
|
+
split_span["text"] = part
|
|
664
|
+
current_spans.append(split_span)
|
|
665
|
+
if part_index < len(parts) - 1:
|
|
666
|
+
split_component = _clone_text_component_with_spans(
|
|
667
|
+
component, current_spans
|
|
668
|
+
)
|
|
669
|
+
if split_component:
|
|
670
|
+
split_components.append(split_component)
|
|
671
|
+
current_spans = []
|
|
672
|
+
if not saw_newline:
|
|
673
|
+
return [component]
|
|
674
|
+
split_component = _clone_text_component_with_spans(component, current_spans)
|
|
675
|
+
if split_component:
|
|
676
|
+
split_components.append(split_component)
|
|
677
|
+
return split_components
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _normalize_link_adjacent_text_segment(
|
|
681
|
+
segment: str, *, trim_start: bool, trim_end: bool
|
|
682
|
+
) -> str:
|
|
683
|
+
"""Drop separator spaces that only existed around an inline link in Markdown."""
|
|
684
|
+
if trim_start:
|
|
685
|
+
segment = segment.lstrip(" \t")
|
|
686
|
+
if trim_end:
|
|
687
|
+
segment = segment.rstrip(" \t")
|
|
688
|
+
return segment
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _process_line_to_flex_components_without_code_spans(
|
|
692
|
+
line, base_style: dict[str, bool] | None = None
|
|
693
|
+
):
|
|
694
|
+
"""インラインコードの外側だけをリンク化し、通常テキストは装飾つきのFlex spanに変換する。"""
|
|
695
|
+
line_components = []
|
|
696
|
+
inline_code_ranges = _inline_code_span_ranges(line)
|
|
697
|
+
last_index = 0
|
|
698
|
+
for link in _iter_links(line):
|
|
699
|
+
match_start, match_end = link.start, link.end
|
|
700
|
+
if _position_inside_ranges(match_start, inline_code_ranges):
|
|
701
|
+
continue
|
|
702
|
+
if match_start > last_index:
|
|
703
|
+
segment = _normalize_link_adjacent_text_segment(
|
|
704
|
+
line[last_index:match_start], trim_start=last_index > 0, trim_end=True
|
|
705
|
+
)
|
|
706
|
+
if segment.strip("。、,・//|| "):
|
|
707
|
+
spans = process_text_segment_to_spans(segment, base_style)
|
|
708
|
+
if spans:
|
|
709
|
+
line_components.append(
|
|
710
|
+
{
|
|
711
|
+
"type": "text",
|
|
712
|
+
"contents": spans,
|
|
713
|
+
"wrap": True,
|
|
714
|
+
"margin": "none",
|
|
715
|
+
"size": "sm",
|
|
716
|
+
}
|
|
717
|
+
)
|
|
718
|
+
action_uri = None
|
|
719
|
+
markdown_text_raw = None
|
|
720
|
+
link_consumed_end = match_end
|
|
721
|
+
if link.kind == "markdown":
|
|
722
|
+
markdown_text_raw = link.label
|
|
723
|
+
action_uri = link.url
|
|
724
|
+
if action_uri and action_uri.startswith("<") and action_uri.endswith(">"):
|
|
725
|
+
action_uri = action_uri[1:-1].strip()
|
|
726
|
+
if not action_uri:
|
|
727
|
+
last_index = match_end
|
|
728
|
+
continue
|
|
729
|
+
elif link.kind == "autolink":
|
|
730
|
+
markdown_text_raw = None
|
|
731
|
+
action_uri = link.url
|
|
732
|
+
elif link.kind == "plain":
|
|
733
|
+
markdown_text_raw = None
|
|
734
|
+
original_plain_url = link.url
|
|
735
|
+
action_uri = _strip_plain_url_punctuation(original_plain_url)
|
|
736
|
+
if not action_uri:
|
|
737
|
+
last_index = match_end
|
|
738
|
+
continue
|
|
739
|
+
link_consumed_end = match_start + len(action_uri)
|
|
740
|
+
else:
|
|
741
|
+
last_index = match_end
|
|
742
|
+
continue
|
|
743
|
+
link_component = None
|
|
744
|
+
if action_uri:
|
|
745
|
+
action_uri = action_uri.strip()
|
|
746
|
+
if not action_uri.startswith(("http://", "https://")):
|
|
747
|
+
# Unsupported destinations never become actions. Preserve the
|
|
748
|
+
# useful label without exposing a local or custom-scheme URI.
|
|
749
|
+
segment = markdown_text_raw or link.text
|
|
750
|
+
spans = process_text_segment_to_spans(segment, base_style)
|
|
751
|
+
if spans:
|
|
752
|
+
line_components.append(
|
|
753
|
+
{
|
|
754
|
+
"type": "text",
|
|
755
|
+
"contents": spans,
|
|
756
|
+
"wrap": True,
|
|
757
|
+
"margin": "none",
|
|
758
|
+
"size": "sm",
|
|
759
|
+
}
|
|
760
|
+
)
|
|
761
|
+
last_index = match_end
|
|
762
|
+
continue
|
|
763
|
+
try:
|
|
764
|
+
parsed_uri = urllib.parse.urlsplit(action_uri)
|
|
765
|
+
if not parsed_uri.scheme or not parsed_uri.hostname:
|
|
766
|
+
raise ValueError("Invalid URL structure (missing scheme or netloc)")
|
|
767
|
+
valid_hostname_pattern = "^[a-zA-Z0-9\\.\\-:]+$"
|
|
768
|
+
if not re.fullmatch(valid_hostname_pattern, parsed_uri.netloc):
|
|
769
|
+
raise ValueError(
|
|
770
|
+
f"Invalid hostname characters in netloc: {parsed_uri.netloc}"
|
|
771
|
+
)
|
|
772
|
+
# Accessing port validates both its numeric form and range.
|
|
773
|
+
_ = parsed_uri.port
|
|
774
|
+
if re.search(r"%(?![0-9A-Fa-f]{2})", action_uri):
|
|
775
|
+
raise ValueError("Invalid percent escape")
|
|
776
|
+
# RFC 3986 pchar, plus the separators allowed in each component.
|
|
777
|
+
path_safe = "/:@!$&'()*+,;=%"
|
|
778
|
+
sanitized_path = urllib.parse.quote(parsed_uri.path, safe=path_safe)
|
|
779
|
+
sanitized_query = urllib.parse.quote(
|
|
780
|
+
parsed_uri.query, safe=path_safe + "?"
|
|
781
|
+
)
|
|
782
|
+
sanitized_fragment = urllib.parse.quote(
|
|
783
|
+
parsed_uri.fragment, safe=path_safe + "?"
|
|
784
|
+
)
|
|
785
|
+
sanitized_uri = (
|
|
786
|
+
f"{parsed_uri.scheme}://{parsed_uri.netloc}{sanitized_path}"
|
|
787
|
+
)
|
|
788
|
+
if "?" in action_uri.partition("#")[0]:
|
|
789
|
+
sanitized_uri += f"?{sanitized_query}"
|
|
790
|
+
if "#" in action_uri:
|
|
791
|
+
sanitized_uri += f"#{sanitized_fragment}"
|
|
792
|
+
action_uri = sanitized_uri
|
|
793
|
+
except ValueError:
|
|
794
|
+
segment = link.text
|
|
795
|
+
spans = process_text_segment_to_spans(segment, base_style)
|
|
796
|
+
if spans:
|
|
797
|
+
line_components.append(
|
|
798
|
+
{
|
|
799
|
+
"type": "text",
|
|
800
|
+
"contents": spans,
|
|
801
|
+
"wrap": True,
|
|
802
|
+
"margin": "none",
|
|
803
|
+
"size": "sm",
|
|
804
|
+
}
|
|
805
|
+
)
|
|
806
|
+
last_index = match_end
|
|
807
|
+
continue
|
|
808
|
+
if len(action_uri) > LINE_ACTION_URI_MAX_LENGTH:
|
|
809
|
+
segment = link.text
|
|
810
|
+
spans = process_text_segment_to_spans(segment, base_style)
|
|
811
|
+
if spans:
|
|
812
|
+
line_components.append(
|
|
813
|
+
{
|
|
814
|
+
"type": "text",
|
|
815
|
+
"contents": spans,
|
|
816
|
+
"wrap": True,
|
|
817
|
+
"margin": "none",
|
|
818
|
+
"size": "sm",
|
|
819
|
+
}
|
|
820
|
+
)
|
|
821
|
+
last_index = match_end
|
|
822
|
+
continue
|
|
823
|
+
is_markdown_link = link.kind == "markdown"
|
|
824
|
+
is_autolink = link.kind == "autolink"
|
|
825
|
+
display_text_source = ""
|
|
826
|
+
if is_markdown_link:
|
|
827
|
+
display_text_source = (
|
|
828
|
+
markdown_text_raw.strip()
|
|
829
|
+
if markdown_text_raw and markdown_text_raw.strip()
|
|
830
|
+
else action_uri
|
|
831
|
+
)
|
|
832
|
+
elif is_autolink:
|
|
833
|
+
display_text_source = action_uri
|
|
834
|
+
else:
|
|
835
|
+
display_text_source = action_uri
|
|
836
|
+
display_spans = (
|
|
837
|
+
process_text_segment_to_spans(display_text_source, base_style)
|
|
838
|
+
if is_markdown_link and markdown_text_raw and markdown_text_raw.strip()
|
|
839
|
+
else [_span_for_text(action_uri, base_style or {})]
|
|
840
|
+
)
|
|
841
|
+
total_text_length = sum(len(span.get("text", "")) for span in display_spans)
|
|
842
|
+
if total_text_length > 40:
|
|
843
|
+
remaining = 37
|
|
844
|
+
shortened = []
|
|
845
|
+
for span in display_spans:
|
|
846
|
+
if remaining <= 0:
|
|
847
|
+
break
|
|
848
|
+
clipped = dict(span)
|
|
849
|
+
clipped["text"] = span["text"][:remaining]
|
|
850
|
+
remaining -= len(clipped["text"])
|
|
851
|
+
shortened.append(clipped)
|
|
852
|
+
shortened[-1]["text"] += "..."
|
|
853
|
+
display_spans = shortened
|
|
854
|
+
link_icon_span = {
|
|
855
|
+
"type": "span",
|
|
856
|
+
"text": "🔗 ",
|
|
857
|
+
"color": LINK_TEXT_COLOR,
|
|
858
|
+
"size": "sm",
|
|
859
|
+
"decoration": "underline",
|
|
860
|
+
}
|
|
861
|
+
final_spans = [link_icon_span]
|
|
862
|
+
for span in display_spans:
|
|
863
|
+
span["color"] = LINK_TEXT_COLOR
|
|
864
|
+
span["decoration"] = "underline"
|
|
865
|
+
final_spans.append(span)
|
|
866
|
+
link_component = {
|
|
867
|
+
"type": "text",
|
|
868
|
+
"contents": final_spans,
|
|
869
|
+
"wrap": True,
|
|
870
|
+
"margin": "sm",
|
|
871
|
+
"action": {"type": "uri", "uri": action_uri},
|
|
872
|
+
}
|
|
873
|
+
if link_component:
|
|
874
|
+
line_components.append(link_component)
|
|
875
|
+
last_index = link_consumed_end
|
|
876
|
+
if last_index < len(line):
|
|
877
|
+
segment = _normalize_link_adjacent_text_segment(
|
|
878
|
+
line[last_index:], trim_start=last_index > 0, trim_end=False
|
|
879
|
+
)
|
|
880
|
+
if segment and segment.strip("。、,・//|| "):
|
|
881
|
+
spans = process_text_segment_to_spans(segment, base_style)
|
|
882
|
+
if spans:
|
|
883
|
+
line_components.append(
|
|
884
|
+
{
|
|
885
|
+
"type": "text",
|
|
886
|
+
"contents": spans,
|
|
887
|
+
"wrap": True,
|
|
888
|
+
"margin": "none",
|
|
889
|
+
"size": "sm",
|
|
890
|
+
}
|
|
891
|
+
)
|
|
892
|
+
if not line_components and last_index == 0 and line.strip():
|
|
893
|
+
if line.strip().strip("。、,・//|| "):
|
|
894
|
+
spans = process_text_segment_to_spans(line.strip(), base_style)
|
|
895
|
+
if spans:
|
|
896
|
+
line_components.append(
|
|
897
|
+
{
|
|
898
|
+
"type": "text",
|
|
899
|
+
"contents": spans,
|
|
900
|
+
"wrap": True,
|
|
901
|
+
"margin": "none",
|
|
902
|
+
"size": "sm",
|
|
903
|
+
}
|
|
904
|
+
)
|
|
905
|
+
return line_components
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
_MARKDOWN_HARD_BREAK_RE = re.compile("( {2,}|<br\\s*/?>)\\s*$", flags=re.IGNORECASE)
|
|
909
|
+
_UNORDERED_LIST_MATCH_RE = re.compile(
|
|
910
|
+
"^(\\s*)(?:(?:[-*+‐•]\\s+)|(?:・\\s*))([\\s\\S]+)$"
|
|
911
|
+
)
|
|
912
|
+
_LIST_ITEM_MARKER_RE = re.compile(
|
|
913
|
+
"^\\s*(?:(?:[-*+‐•]\\s+)|(?:・\\s*)|\\d+[.)]\\s+)(?P<text>.*)$"
|
|
914
|
+
)
|
|
915
|
+
_LIST_ITEM_TITLE_CONTENT_RE = re.compile("^(\\*{1,3})(?!\\s).+?(?<!\\s)\\1(?:[::])?$")
|
|
916
|
+
_TABLE_BLOCK_TERMINATOR_RE = re.compile(
|
|
917
|
+
"^\\s*(?:#{1,6}\\s+|(?:[-*+‐•]\\s+|・\\s*)|\\d+[.)]\\s+|>)"
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _ends_with_markdown_hard_break(text: str) -> bool:
|
|
922
|
+
"""末尾行が Markdown のハード改行マーカー(末尾2スペース / <br>)で終わるか判定する。"""
|
|
923
|
+
last_line = text.split("\n")[-1]
|
|
924
|
+
return bool(_MARKDOWN_HARD_BREAK_RE.search(last_line))
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _strip_markdown_hard_break(text: str) -> str:
|
|
928
|
+
"""末尾行の Markdown ハード改行マーカー(末尾2スペース / <br>)を除去する。"""
|
|
929
|
+
lines = text.split("\n")
|
|
930
|
+
if not lines:
|
|
931
|
+
return text
|
|
932
|
+
lines[-1] = _MARKDOWN_HARD_BREAK_RE.sub("", lines[-1])
|
|
933
|
+
return "\n".join(lines)
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _looks_like_list_item_title_line(text: str) -> bool:
|
|
937
|
+
"""リストの末尾行がタイトルとみなせる場合にTrueを返す(改行として扱う)。"""
|
|
938
|
+
last_line = text.split("\n")[-1].rstrip()
|
|
939
|
+
match = _LIST_ITEM_MARKER_RE.match(last_line)
|
|
940
|
+
if not match:
|
|
941
|
+
return False
|
|
942
|
+
item_text = (match.group("text") or "").strip()
|
|
943
|
+
if not item_text:
|
|
944
|
+
return False
|
|
945
|
+
if item_text.endswith((":", ":")):
|
|
946
|
+
return True
|
|
947
|
+
return bool(_LIST_ITEM_TITLE_CONTENT_RE.fullmatch(item_text))
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _allows_multiline_inline_continuation(line: str) -> bool:
|
|
951
|
+
stripped = line.strip()
|
|
952
|
+
if not stripped:
|
|
953
|
+
return False
|
|
954
|
+
if stripped.startswith(">"):
|
|
955
|
+
return False
|
|
956
|
+
if re.match("^(#{1,6})\\s+(.*)", stripped):
|
|
957
|
+
return False
|
|
958
|
+
if re.match("^(\\s*)(?:[-‐]{3,}|\\*{3,}|_{3,})\\s*$", stripped):
|
|
959
|
+
return False
|
|
960
|
+
if _UNORDERED_LIST_MATCH_RE.match(line):
|
|
961
|
+
return False
|
|
962
|
+
if re.match("^(\\s*)(\\d+)([.)])\\s+(.*)", line):
|
|
963
|
+
return False
|
|
964
|
+
if re.match("^\\s+.+", line):
|
|
965
|
+
return False
|
|
966
|
+
return True
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _collect_multiline_inline_block(
|
|
970
|
+
lines: list[str], start_index: int, closing_line_cache: dict[str, list[int | None]]
|
|
971
|
+
) -> tuple[str, int] | None:
|
|
972
|
+
start_line = lines[start_index].strip()
|
|
973
|
+
opener = _find_unclosed_inline_delimiter(start_line)
|
|
974
|
+
if opener is None:
|
|
975
|
+
return None
|
|
976
|
+
delimiter, opener_index = opener
|
|
977
|
+
if (
|
|
978
|
+
_find_closing_inline_delimiter(
|
|
979
|
+
start_line, opener_index + len(delimiter), delimiter
|
|
980
|
+
)
|
|
981
|
+
is not None
|
|
982
|
+
):
|
|
983
|
+
return None
|
|
984
|
+
if delimiter not in closing_line_cache:
|
|
985
|
+
closing_lines: list[int | None] = [None] * len(lines)
|
|
986
|
+
next_closing_line: int | None = None
|
|
987
|
+
for index in range(len(lines) - 1, -1, -1):
|
|
988
|
+
closing_lines[index] = next_closing_line
|
|
989
|
+
line = lines[index]
|
|
990
|
+
if not _allows_multiline_inline_continuation(line):
|
|
991
|
+
next_closing_line = None
|
|
992
|
+
elif _find_closing_inline_delimiter(line.strip(), 0, delimiter) is not None:
|
|
993
|
+
next_closing_line = index
|
|
994
|
+
closing_line_cache[delimiter] = closing_lines
|
|
995
|
+
closing_line = closing_line_cache[delimiter][start_index]
|
|
996
|
+
if closing_line is None:
|
|
997
|
+
return None
|
|
998
|
+
joined = "\n".join(line.strip() for line in lines[start_index : closing_line + 1])
|
|
999
|
+
return (joined, closing_line + 1)
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def process_text_block_to_flex_components(text_block: str) -> list[dict[str, Any]]:
|
|
1003
|
+
"""複数行を含むテキストを Flex コンポーネントのフラットなリストへ変換する。"""
|
|
1004
|
+
components: list[dict[str, Any]] = []
|
|
1005
|
+
normalized_lines = [
|
|
1006
|
+
raw_line.strip() for raw_line in text_block.splitlines() if raw_line.strip()
|
|
1007
|
+
]
|
|
1008
|
+
if not normalized_lines:
|
|
1009
|
+
return components
|
|
1010
|
+
line_components = process_line_to_flex_components("\n".join(normalized_lines))
|
|
1011
|
+
for component in line_components:
|
|
1012
|
+
components.extend(_split_text_component_on_newlines(component))
|
|
1013
|
+
return components
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def _paragraph_box(
|
|
1017
|
+
components: list[dict[str, Any]], *, margin: str = "md"
|
|
1018
|
+
) -> dict[str, Any] | None:
|
|
1019
|
+
non_empty = [component for component in components if component]
|
|
1020
|
+
if not non_empty:
|
|
1021
|
+
return None
|
|
1022
|
+
return {
|
|
1023
|
+
"type": "box",
|
|
1024
|
+
"layout": "vertical",
|
|
1025
|
+
"spacing": "xs",
|
|
1026
|
+
"contents": non_empty,
|
|
1027
|
+
"margin": margin,
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _spacer_component() -> dict[str, Any]:
|
|
1032
|
+
return {
|
|
1033
|
+
"type": "text",
|
|
1034
|
+
"text": " ",
|
|
1035
|
+
"size": "xxs",
|
|
1036
|
+
"margin": "none",
|
|
1037
|
+
"color": "#FFFFFF",
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _preserve_code_block_indentation(code: str) -> str:
|
|
1042
|
+
"""Keep leading indentation visible in LINE clients that collapse ASCII spaces."""
|
|
1043
|
+
preserved_lines: list[str] = []
|
|
1044
|
+
for line in code.split("\n"):
|
|
1045
|
+
expanded_line = line.expandtabs(4)
|
|
1046
|
+
stripped_line = expanded_line.lstrip(" ")
|
|
1047
|
+
leading_space_count = len(expanded_line) - len(stripped_line)
|
|
1048
|
+
preserved_lines.append(
|
|
1049
|
+
CODE_BLOCK_INDENT_SPACE * leading_space_count + stripped_line
|
|
1050
|
+
)
|
|
1051
|
+
return "\n".join(preserved_lines)
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def _render_code_block(raw_code: str) -> dict[str, Any] | None:
|
|
1055
|
+
code_content = raw_code[3:-3]
|
|
1056
|
+
language = ""
|
|
1057
|
+
body = code_content
|
|
1058
|
+
if "\n" in code_content:
|
|
1059
|
+
first_line, rest = code_content.split("\n", 1)
|
|
1060
|
+
candidate_language = first_line.strip()
|
|
1061
|
+
if not candidate_language:
|
|
1062
|
+
body = rest.rstrip("\n")
|
|
1063
|
+
elif re.fullmatch("[A-Za-z0-9_+#.\\-]+", candidate_language):
|
|
1064
|
+
language = candidate_language
|
|
1065
|
+
body = rest.rstrip("\n")
|
|
1066
|
+
if not language and (not body.strip()):
|
|
1067
|
+
return None
|
|
1068
|
+
block_contents: list[dict[str, Any]] = []
|
|
1069
|
+
if language:
|
|
1070
|
+
block_contents.append(
|
|
1071
|
+
{
|
|
1072
|
+
"type": "text",
|
|
1073
|
+
"text": language,
|
|
1074
|
+
"wrap": True,
|
|
1075
|
+
"color": MUTED_TEXT_COLOR,
|
|
1076
|
+
"size": "xxs",
|
|
1077
|
+
"weight": "bold",
|
|
1078
|
+
"margin": "none",
|
|
1079
|
+
}
|
|
1080
|
+
)
|
|
1081
|
+
if body.strip():
|
|
1082
|
+
block_contents.append(
|
|
1083
|
+
{
|
|
1084
|
+
"type": "text",
|
|
1085
|
+
"text": _preserve_code_block_indentation(body),
|
|
1086
|
+
"wrap": True,
|
|
1087
|
+
"color": "#333333",
|
|
1088
|
+
"size": "sm",
|
|
1089
|
+
"margin": "xs" if language else "none",
|
|
1090
|
+
}
|
|
1091
|
+
)
|
|
1092
|
+
return {
|
|
1093
|
+
"type": "box",
|
|
1094
|
+
"layout": "vertical",
|
|
1095
|
+
"contents": block_contents,
|
|
1096
|
+
"backgroundColor": CODE_BLOCK_BACKGROUND,
|
|
1097
|
+
"paddingAll": "10px",
|
|
1098
|
+
"cornerRadius": "md",
|
|
1099
|
+
"margin": "md",
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def _render_heading(heading_text: str, level: int) -> dict[str, Any] | None:
|
|
1104
|
+
spans = process_text_segment_to_spans(heading_text, {"bold": True})
|
|
1105
|
+
if not spans:
|
|
1106
|
+
return None
|
|
1107
|
+
size = "xxl" if level == 1 else "xl" if level == 2 else "lg"
|
|
1108
|
+
for span in spans:
|
|
1109
|
+
span["weight"] = "bold"
|
|
1110
|
+
span["size"] = size
|
|
1111
|
+
if not span.get("code"):
|
|
1112
|
+
span["color"] = "#444444"
|
|
1113
|
+
return {
|
|
1114
|
+
"type": "text",
|
|
1115
|
+
"contents": spans,
|
|
1116
|
+
"wrap": True,
|
|
1117
|
+
"weight": "bold",
|
|
1118
|
+
"size": size,
|
|
1119
|
+
"margin": "xs",
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def _render_separator() -> dict[str, Any]:
|
|
1124
|
+
return {
|
|
1125
|
+
"type": "text",
|
|
1126
|
+
"text": "───────────────────────",
|
|
1127
|
+
"wrap": True,
|
|
1128
|
+
"color": "#CCCCCC",
|
|
1129
|
+
"size": "sm",
|
|
1130
|
+
"align": "center",
|
|
1131
|
+
"margin": "none",
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def _render_quote_block(quote_text: str) -> dict[str, Any] | None:
|
|
1136
|
+
inner_components = process_text_block_to_flex_components(quote_text)
|
|
1137
|
+
if not inner_components:
|
|
1138
|
+
return None
|
|
1139
|
+
return {
|
|
1140
|
+
"type": "box",
|
|
1141
|
+
"layout": "horizontal",
|
|
1142
|
+
"contents": [
|
|
1143
|
+
{
|
|
1144
|
+
"type": "box",
|
|
1145
|
+
"layout": "vertical",
|
|
1146
|
+
"contents": [
|
|
1147
|
+
{"type": "text", "text": " ", "size": "xxs", "color": "#FFB6C1"}
|
|
1148
|
+
],
|
|
1149
|
+
"width": "4px",
|
|
1150
|
+
"backgroundColor": "#FFB6C1",
|
|
1151
|
+
"cornerRadius": "2px",
|
|
1152
|
+
},
|
|
1153
|
+
{
|
|
1154
|
+
"type": "box",
|
|
1155
|
+
"layout": "vertical",
|
|
1156
|
+
"contents": inner_components,
|
|
1157
|
+
"spacing": "xs",
|
|
1158
|
+
"paddingStart": "10px",
|
|
1159
|
+
"flex": 1,
|
|
1160
|
+
},
|
|
1161
|
+
],
|
|
1162
|
+
"backgroundColor": "#FFF7FA",
|
|
1163
|
+
"paddingAll": "8px",
|
|
1164
|
+
"cornerRadius": "md",
|
|
1165
|
+
"margin": "md",
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def _is_markdown_table_separator(line: str) -> bool:
|
|
1170
|
+
stripped = line.strip()
|
|
1171
|
+
if "|" not in stripped:
|
|
1172
|
+
return False
|
|
1173
|
+
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
|
|
1174
|
+
if len(cells) < 2:
|
|
1175
|
+
return False
|
|
1176
|
+
return all(re.fullmatch(":?-{3,}:?", cell or "") for cell in cells)
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def _looks_like_markdown_table_start(lines: list[str], index: int) -> bool:
|
|
1180
|
+
if index + 1 >= len(lines):
|
|
1181
|
+
return False
|
|
1182
|
+
header = lines[index].strip()
|
|
1183
|
+
if "|" not in header or header.count("|") < 1:
|
|
1184
|
+
return False
|
|
1185
|
+
return _is_markdown_table_separator(lines[index + 1])
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def _render_table_block(table_lines: list[str]) -> dict[str, Any] | None:
|
|
1189
|
+
table_text = "\n".join(line.strip() for line in table_lines if line.strip())
|
|
1190
|
+
if not table_text:
|
|
1191
|
+
return None
|
|
1192
|
+
return {
|
|
1193
|
+
"type": "box",
|
|
1194
|
+
"layout": "vertical",
|
|
1195
|
+
"contents": [
|
|
1196
|
+
{
|
|
1197
|
+
"type": "text",
|
|
1198
|
+
"text": "table",
|
|
1199
|
+
"wrap": True,
|
|
1200
|
+
"color": MUTED_TEXT_COLOR,
|
|
1201
|
+
"size": "xxs",
|
|
1202
|
+
"weight": "bold",
|
|
1203
|
+
"margin": "none",
|
|
1204
|
+
},
|
|
1205
|
+
{
|
|
1206
|
+
"type": "text",
|
|
1207
|
+
"text": table_text,
|
|
1208
|
+
"wrap": True,
|
|
1209
|
+
"color": "#333333",
|
|
1210
|
+
"size": "xs",
|
|
1211
|
+
"margin": "xs",
|
|
1212
|
+
},
|
|
1213
|
+
],
|
|
1214
|
+
"backgroundColor": CODE_BLOCK_BACKGROUND,
|
|
1215
|
+
"paddingAll": "10px",
|
|
1216
|
+
"cornerRadius": "md",
|
|
1217
|
+
"margin": "md",
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def _task_list_prefix(raw_list_item_text: str) -> tuple[str, str]:
|
|
1222
|
+
task_match = re.match(
|
|
1223
|
+
"^\\[(?P<mark>[ xX])\\]\\s+(?P<text>[\\s\\S]+)$", raw_list_item_text
|
|
1224
|
+
)
|
|
1225
|
+
if not task_match:
|
|
1226
|
+
return ("", raw_list_item_text)
|
|
1227
|
+
marker = "☑ " if task_match.group("mark").lower() == "x" else "☐ "
|
|
1228
|
+
return (marker, task_match.group("text").strip())
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def _render_list_item(
|
|
1232
|
+
bullet_text: str,
|
|
1233
|
+
raw_list_item_text: str,
|
|
1234
|
+
indent_level: int,
|
|
1235
|
+
*,
|
|
1236
|
+
ordered: bool = False,
|
|
1237
|
+
) -> dict[str, Any] | None:
|
|
1238
|
+
task_prefix, item_text = _task_list_prefix(raw_list_item_text.strip())
|
|
1239
|
+
display_bullet = task_prefix or bullet_text
|
|
1240
|
+
if indent_level == 0:
|
|
1241
|
+
padding_start = "10px"
|
|
1242
|
+
marker = display_bullet
|
|
1243
|
+
elif indent_level <= 2:
|
|
1244
|
+
padding_start = "30px"
|
|
1245
|
+
marker = display_bullet if ordered or task_prefix else "◦ "
|
|
1246
|
+
else:
|
|
1247
|
+
padding_start = "50px"
|
|
1248
|
+
marker = display_bullet if ordered or task_prefix else "▪ "
|
|
1249
|
+
item_components = process_text_block_to_flex_components(item_text)
|
|
1250
|
+
if not item_components:
|
|
1251
|
+
return None
|
|
1252
|
+
return {
|
|
1253
|
+
"type": "box",
|
|
1254
|
+
"layout": "horizontal",
|
|
1255
|
+
"contents": [
|
|
1256
|
+
{
|
|
1257
|
+
"type": "text",
|
|
1258
|
+
"text": marker,
|
|
1259
|
+
"wrap": False,
|
|
1260
|
+
"flex": 0,
|
|
1261
|
+
"margin": "none",
|
|
1262
|
+
"gravity": "top",
|
|
1263
|
+
"size": "sm",
|
|
1264
|
+
"color": BODY_TEXT_COLOR,
|
|
1265
|
+
},
|
|
1266
|
+
{
|
|
1267
|
+
"type": "box",
|
|
1268
|
+
"layout": "vertical",
|
|
1269
|
+
"contents": item_components,
|
|
1270
|
+
"flex": 1,
|
|
1271
|
+
"margin": "sm",
|
|
1272
|
+
"spacing": "xs",
|
|
1273
|
+
},
|
|
1274
|
+
],
|
|
1275
|
+
"margin": "xs",
|
|
1276
|
+
"paddingStart": padding_start,
|
|
1277
|
+
"alignItems": "flex-start",
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def parse_text_with_links(text):
|
|
1282
|
+
"""テキストを解析し、コードブロック、見出し、リスト、リンク、太字をFlex Messageのコンテンツに変換する。"""
|
|
1283
|
+
code_delimiter_pattern = "(```[\\s\\S]*?```)"
|
|
1284
|
+
segments = re.split(code_delimiter_pattern, text)
|
|
1285
|
+
contents = []
|
|
1286
|
+
for segment in segments:
|
|
1287
|
+
if segment is None or not segment.strip():
|
|
1288
|
+
continue
|
|
1289
|
+
is_code_block = re.fullmatch(code_delimiter_pattern, segment)
|
|
1290
|
+
if is_code_block:
|
|
1291
|
+
component_to_add = _render_code_block(segment)
|
|
1292
|
+
if component_to_add:
|
|
1293
|
+
contents.append(component_to_add)
|
|
1294
|
+
else:
|
|
1295
|
+
normalized_segment = "\n".join(
|
|
1296
|
+
_transform_outside_inline_code(
|
|
1297
|
+
line,
|
|
1298
|
+
lambda source: re.sub(
|
|
1299
|
+
"<br\\s*/?>", "\n", source, flags=re.IGNORECASE
|
|
1300
|
+
),
|
|
1301
|
+
)
|
|
1302
|
+
for line in segment.split("\n")
|
|
1303
|
+
)
|
|
1304
|
+
lines = normalized_segment.strip().split("\n")
|
|
1305
|
+
normalized_lines = []
|
|
1306
|
+
prev_was_list = False
|
|
1307
|
+
for raw_line in lines:
|
|
1308
|
+
stripped = raw_line.strip()
|
|
1309
|
+
heading_match_tmp = re.match("^(#{1,6})\\s+(.*)", stripped)
|
|
1310
|
+
separator_line_match_tmp = re.match(
|
|
1311
|
+
"^(\\s*)(?:[-‐]{3,}|\\*{3,}|_{3,})\\s*$", stripped
|
|
1312
|
+
)
|
|
1313
|
+
list_match_tmp = (
|
|
1314
|
+
_UNORDERED_LIST_MATCH_RE.match(raw_line)
|
|
1315
|
+
if not separator_line_match_tmp
|
|
1316
|
+
else None
|
|
1317
|
+
)
|
|
1318
|
+
numbered_list_match_tmp = re.match(
|
|
1319
|
+
"^(\\s*)(\\d+)([.)])\\s+(.*)", raw_line
|
|
1320
|
+
)
|
|
1321
|
+
is_list_line = bool(list_match_tmp or numbered_list_match_tmp)
|
|
1322
|
+
should_merge_with_prev_list = (
|
|
1323
|
+
not is_list_line
|
|
1324
|
+
and prev_was_list
|
|
1325
|
+
and stripped
|
|
1326
|
+
and raw_line[:1].isspace()
|
|
1327
|
+
and (not heading_match_tmp)
|
|
1328
|
+
and (not separator_line_match_tmp)
|
|
1329
|
+
)
|
|
1330
|
+
if should_merge_with_prev_list and normalized_lines:
|
|
1331
|
+
continuation = raw_line.lstrip()
|
|
1332
|
+
prev_line = normalized_lines[-1]
|
|
1333
|
+
if _ends_with_markdown_hard_break(
|
|
1334
|
+
prev_line
|
|
1335
|
+
) or _looks_like_list_item_title_line(prev_line):
|
|
1336
|
+
prev_line = _strip_markdown_hard_break(prev_line).rstrip()
|
|
1337
|
+
normalized_lines[-1] = prev_line + "\n" + continuation
|
|
1338
|
+
else:
|
|
1339
|
+
normalized_lines[-1] = prev_line.rstrip() + " " + continuation
|
|
1340
|
+
prev_was_list = True
|
|
1341
|
+
continue
|
|
1342
|
+
normalized_lines.append(raw_line)
|
|
1343
|
+
prev_was_list = is_list_line
|
|
1344
|
+
lines = normalized_lines
|
|
1345
|
+
closing_line_cache: dict[str, list[int | None]] = {}
|
|
1346
|
+
current_paragraph_components = []
|
|
1347
|
+
|
|
1348
|
+
def add_paragraph_if_exists(components=current_paragraph_components):
|
|
1349
|
+
paragraph_box = _paragraph_box(components)
|
|
1350
|
+
if paragraph_box:
|
|
1351
|
+
contents.append(paragraph_box)
|
|
1352
|
+
components.clear()
|
|
1353
|
+
|
|
1354
|
+
line_index = 0
|
|
1355
|
+
while line_index < len(lines):
|
|
1356
|
+
line = lines[line_index]
|
|
1357
|
+
line_strip = line.strip()
|
|
1358
|
+
if not line_strip:
|
|
1359
|
+
add_paragraph_if_exists()
|
|
1360
|
+
contents.append(_spacer_component())
|
|
1361
|
+
line_index += 1
|
|
1362
|
+
continue
|
|
1363
|
+
heading_match = re.match("^(#{1,6})\\s+(.*)", line_strip)
|
|
1364
|
+
separator_line_match = re.match(
|
|
1365
|
+
"^(\\s*)(?:[-‐]{3,}|\\*{3,}|_{3,})\\s*$", line_strip
|
|
1366
|
+
)
|
|
1367
|
+
list_match = (
|
|
1368
|
+
_UNORDERED_LIST_MATCH_RE.match(line)
|
|
1369
|
+
if not separator_line_match
|
|
1370
|
+
else None
|
|
1371
|
+
)
|
|
1372
|
+
numbered_list_match = re.match(
|
|
1373
|
+
"^(\\s*)(\\d+)([.)])\\s+([\\s\\S]+)", line
|
|
1374
|
+
)
|
|
1375
|
+
if _looks_like_markdown_table_start(lines, line_index):
|
|
1376
|
+
add_paragraph_if_exists()
|
|
1377
|
+
table_lines = [lines[line_index], lines[line_index + 1]]
|
|
1378
|
+
line_index += 2
|
|
1379
|
+
while line_index < len(lines):
|
|
1380
|
+
candidate_line = lines[line_index]
|
|
1381
|
+
if not candidate_line.strip() or "|" not in candidate_line:
|
|
1382
|
+
break
|
|
1383
|
+
if _TABLE_BLOCK_TERMINATOR_RE.match(candidate_line):
|
|
1384
|
+
break
|
|
1385
|
+
table_lines.append(candidate_line)
|
|
1386
|
+
line_index += 1
|
|
1387
|
+
table_component = _render_table_block(table_lines)
|
|
1388
|
+
if table_component:
|
|
1389
|
+
contents.append(table_component)
|
|
1390
|
+
continue
|
|
1391
|
+
if line_strip.startswith(">"):
|
|
1392
|
+
add_paragraph_if_exists()
|
|
1393
|
+
quote_lines: list[str] = []
|
|
1394
|
+
while line_index < len(lines):
|
|
1395
|
+
quote_line = lines[line_index].strip()
|
|
1396
|
+
if not quote_line.startswith(">"):
|
|
1397
|
+
break
|
|
1398
|
+
quote_lines.append(re.sub("^>\\s?", "", quote_line))
|
|
1399
|
+
line_index += 1
|
|
1400
|
+
quote_component = _render_quote_block(
|
|
1401
|
+
"\n".join(quote_lines).strip()
|
|
1402
|
+
)
|
|
1403
|
+
if quote_component:
|
|
1404
|
+
contents.append(quote_component)
|
|
1405
|
+
continue
|
|
1406
|
+
if heading_match:
|
|
1407
|
+
add_paragraph_if_exists()
|
|
1408
|
+
level = len(heading_match.group(1))
|
|
1409
|
+
heading_text = heading_match.group(2).strip()
|
|
1410
|
+
component_to_add = _render_heading(heading_text, level)
|
|
1411
|
+
if component_to_add:
|
|
1412
|
+
contents.append(component_to_add)
|
|
1413
|
+
line_index += 1
|
|
1414
|
+
continue
|
|
1415
|
+
elif numbered_list_match:
|
|
1416
|
+
add_paragraph_if_exists()
|
|
1417
|
+
indent_spaces = numbered_list_match.group(1)
|
|
1418
|
+
indent_level = len(indent_spaces)
|
|
1419
|
+
list_number = numbered_list_match.group(2)
|
|
1420
|
+
list_delimiter = numbered_list_match.group(3)
|
|
1421
|
+
raw_list_item_text = numbered_list_match.group(4).strip()
|
|
1422
|
+
component_to_add = _render_list_item(
|
|
1423
|
+
f"{list_number}{list_delimiter} ",
|
|
1424
|
+
raw_list_item_text,
|
|
1425
|
+
indent_level,
|
|
1426
|
+
ordered=True,
|
|
1427
|
+
)
|
|
1428
|
+
if component_to_add:
|
|
1429
|
+
contents.append(component_to_add)
|
|
1430
|
+
line_index += 1
|
|
1431
|
+
continue
|
|
1432
|
+
elif list_match:
|
|
1433
|
+
add_paragraph_if_exists()
|
|
1434
|
+
indent_spaces = list_match.group(1)
|
|
1435
|
+
indent_level = len(indent_spaces)
|
|
1436
|
+
raw_list_item_text = list_match.group(2).strip()
|
|
1437
|
+
component_to_add = _render_list_item(
|
|
1438
|
+
"• ", raw_list_item_text, indent_level
|
|
1439
|
+
)
|
|
1440
|
+
if component_to_add:
|
|
1441
|
+
contents.append(component_to_add)
|
|
1442
|
+
line_index += 1
|
|
1443
|
+
continue
|
|
1444
|
+
elif separator_line_match:
|
|
1445
|
+
add_paragraph_if_exists()
|
|
1446
|
+
contents.append(_render_separator())
|
|
1447
|
+
line_index += 1
|
|
1448
|
+
continue
|
|
1449
|
+
is_marker_line = False
|
|
1450
|
+
if (
|
|
1451
|
+
not list_match
|
|
1452
|
+
and (not numbered_list_match)
|
|
1453
|
+
and (not heading_match)
|
|
1454
|
+
and line_strip
|
|
1455
|
+
):
|
|
1456
|
+
if is_char_emoji(line_strip[0]):
|
|
1457
|
+
is_marker_line = True
|
|
1458
|
+
if is_marker_line:
|
|
1459
|
+
add_paragraph_if_exists()
|
|
1460
|
+
marker_multiline_block = _collect_multiline_inline_block(
|
|
1461
|
+
lines, line_index, closing_line_cache
|
|
1462
|
+
)
|
|
1463
|
+
if marker_multiline_block:
|
|
1464
|
+
block_text, next_line_index = marker_multiline_block
|
|
1465
|
+
marker_line_components = process_text_block_to_flex_components(
|
|
1466
|
+
block_text
|
|
1467
|
+
)
|
|
1468
|
+
line_index = next_line_index
|
|
1469
|
+
else:
|
|
1470
|
+
marker_line_components = process_line_to_flex_components(
|
|
1471
|
+
line_strip
|
|
1472
|
+
)
|
|
1473
|
+
line_index += 1
|
|
1474
|
+
if marker_line_components:
|
|
1475
|
+
component_to_add = {
|
|
1476
|
+
"type": "box",
|
|
1477
|
+
"layout": "vertical",
|
|
1478
|
+
"contents": marker_line_components,
|
|
1479
|
+
"margin": "md",
|
|
1480
|
+
"spacing": "xs",
|
|
1481
|
+
}
|
|
1482
|
+
contents.append(component_to_add)
|
|
1483
|
+
continue
|
|
1484
|
+
else:
|
|
1485
|
+
space_indent_match = re.match("^(\\s+)(.+)", line)
|
|
1486
|
+
if (
|
|
1487
|
+
space_indent_match
|
|
1488
|
+
and (not list_match)
|
|
1489
|
+
and (not numbered_list_match)
|
|
1490
|
+
):
|
|
1491
|
+
add_paragraph_if_exists()
|
|
1492
|
+
indent_spaces = space_indent_match.group(1)
|
|
1493
|
+
indent_level = len(indent_spaces)
|
|
1494
|
+
indented_text = space_indent_match.group(2).strip()
|
|
1495
|
+
if indent_level <= 2:
|
|
1496
|
+
padding_start = "20px"
|
|
1497
|
+
elif indent_level <= 4:
|
|
1498
|
+
padding_start = "40px"
|
|
1499
|
+
else:
|
|
1500
|
+
padding_start = "60px"
|
|
1501
|
+
if indented_text:
|
|
1502
|
+
indented_flex_components_list = (
|
|
1503
|
+
process_line_to_flex_components(indented_text)
|
|
1504
|
+
)
|
|
1505
|
+
if indented_flex_components_list:
|
|
1506
|
+
component_to_add = {
|
|
1507
|
+
"type": "box",
|
|
1508
|
+
"layout": "vertical",
|
|
1509
|
+
"contents": indented_flex_components_list,
|
|
1510
|
+
"margin": "xs",
|
|
1511
|
+
"paddingStart": padding_start,
|
|
1512
|
+
}
|
|
1513
|
+
contents.append(component_to_add)
|
|
1514
|
+
line_index += 1
|
|
1515
|
+
continue
|
|
1516
|
+
multiline_inline_block = _collect_multiline_inline_block(
|
|
1517
|
+
lines, line_index, closing_line_cache
|
|
1518
|
+
)
|
|
1519
|
+
if multiline_inline_block:
|
|
1520
|
+
block_text, next_line_index = multiline_inline_block
|
|
1521
|
+
line_flex_components_list = (
|
|
1522
|
+
process_text_block_to_flex_components(block_text)
|
|
1523
|
+
)
|
|
1524
|
+
if line_flex_components_list:
|
|
1525
|
+
current_paragraph_components.extend(
|
|
1526
|
+
comp for comp in line_flex_components_list if comp
|
|
1527
|
+
)
|
|
1528
|
+
line_index = next_line_index
|
|
1529
|
+
continue
|
|
1530
|
+
line_flex_components_list = process_line_to_flex_components(
|
|
1531
|
+
line_strip
|
|
1532
|
+
)
|
|
1533
|
+
if line_flex_components_list:
|
|
1534
|
+
current_paragraph_components.extend(
|
|
1535
|
+
comp for comp in line_flex_components_list if comp
|
|
1536
|
+
)
|
|
1537
|
+
line_index += 1
|
|
1538
|
+
add_paragraph_if_exists()
|
|
1539
|
+
if not contents and text.strip():
|
|
1540
|
+
contents.extend(process_line_to_flex_components(text.strip()))
|
|
1541
|
+
return contents
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
def _is_separator_text_component(component: dict) -> bool:
|
|
1545
|
+
"""Flexのテキストコンポーネントが区切り線かどうかを判定する。"""
|
|
1546
|
+
if not isinstance(component, dict):
|
|
1547
|
+
return False
|
|
1548
|
+
if component.get("type") != "text":
|
|
1549
|
+
return False
|
|
1550
|
+
text_value = component.get("text", "")
|
|
1551
|
+
if not isinstance(text_value, str):
|
|
1552
|
+
return False
|
|
1553
|
+
return re.fullmatch("\\s*[-‐─]{3,}\\s*", text_value) is not None
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
def build_flex_contents(text_message: str) -> list[dict]:
|
|
1557
|
+
"""Render Markdown as Flex body components, without sending or logging it."""
|
|
1558
|
+
if not isinstance(text_message, str):
|
|
1559
|
+
raise TypeError("text_message must be a string")
|
|
1560
|
+
contents = parse_text_with_links(text_message)
|
|
1561
|
+
while contents and _is_separator_text_component(contents[0]):
|
|
1562
|
+
contents.pop(0)
|
|
1563
|
+
while contents and _is_separator_text_component(contents[-1]):
|
|
1564
|
+
contents.pop()
|
|
1565
|
+
return contents
|