md2lineflex 0.1.0__tar.gz
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.
- md2lineflex-0.1.0/LICENSE +21 -0
- md2lineflex-0.1.0/PKG-INFO +107 -0
- md2lineflex-0.1.0/README.md +91 -0
- md2lineflex-0.1.0/pyproject.toml +73 -0
- md2lineflex-0.1.0/pyproject.toml.orig +63 -0
- md2lineflex-0.1.0/src/md2lineflex/__init__.py +11 -0
- md2lineflex-0.1.0/src/md2lineflex/flex_components.py +214 -0
- md2lineflex-0.1.0/src/md2lineflex/flex_message_renderer.py +341 -0
- md2lineflex-0.1.0/src/md2lineflex/llm_utils.py +14 -0
- md2lineflex-0.1.0/src/md2lineflex/md_to_flex.py +45 -0
- md2lineflex-0.1.0/src/md2lineflex/py.typed +0 -0
- md2lineflex-0.1.0/src/md2lineflex/str_utils.py +6 -0
- md2lineflex-0.1.0/src/md2lineflex/theme.py +49 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 parkwestlabs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
16
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
17
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
18
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
|
19
|
+
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
20
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
|
21
|
+
OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: md2lineflex
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Markdown to LINE FlexMessage Converter
|
|
5
|
+
Keywords: markdown,LINE,flexmessage
|
|
6
|
+
Author: parkwestlabs
|
|
7
|
+
Author-email: parkwestlabs <281970052+parkwestlabs@users.noreply.github.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: line-bot-sdk>=3.25.0
|
|
11
|
+
Requires-Dist: mdformat>=1.0.0
|
|
12
|
+
Requires-Dist: mdformat-gfm>=1.0.0
|
|
13
|
+
Requires-Dist: mistletoe>=1.6.0
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# md2lineflex - Markdown to LINE FlexMessage Converter
|
|
18
|
+
|
|
19
|
+
[](https://badge.fury.io/py/md2lineflex)
|
|
20
|
+
[](https://pypi.org/project/md2lineflex/)
|
|
21
|
+
[](https://opensource.org/licenses/MIT)
|
|
22
|
+
|
|
23
|
+
A lightweight Python library that converts **Markdown text into LINE Bot Flex Messages** effortlessly.
|
|
24
|
+
|
|
25
|
+
Designed especially for LLM / Chatbot developers who want to render Markdown outputs (from OpenAI, Claude, etc.) as beautiful, structured LINE Flex Messages instead of plain text.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- 🚀 **Zero-Config Conversion:** Simply pass a Markdown string and get a LINE Messaging API compatible `FlexMessage` object or `dict`.
|
|
32
|
+
- 🎨 **Rich Component Support:** Supports Headings, Paragraphs, Lists, Code Blocks, Quotes, Links, Images, and Tables.
|
|
33
|
+
- 🔗 **Flexible Link Handling:** Render links as embedded URLs, inline text, or interactive LINE URI Action Buttons.
|
|
34
|
+
- 🛡️ **Type Safe:** Fully typed with `py.typed` marker (PEP 561) for modern IDE auto-completion.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# Using uv (Recommended)
|
|
42
|
+
uv add md2lineflex
|
|
43
|
+
|
|
44
|
+
# Using pip
|
|
45
|
+
pip install md2lineflex
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Prerequisite: Python `>= 3.10` and `line-bot-sdk >= 3.0.0`
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Quickstart
|
|
53
|
+
|
|
54
|
+
````python
|
|
55
|
+
from linebot.v3.messaging import PushMessageRequest
|
|
56
|
+
from md2lineflex import to_flex
|
|
57
|
+
|
|
58
|
+
md_text = """
|
|
59
|
+
## Hello LINE Bot!
|
|
60
|
+
|
|
61
|
+
This is **bold text** generated from Markdown.
|
|
62
|
+
|
|
63
|
+
- Feature 1: Automatic Flex Box layout
|
|
64
|
+
- Feature 2: Supports [Links](https://example.com)
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
print("Hello World")
|
|
68
|
+
```
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
# Convert Markdown to FlexMessage object
|
|
72
|
+
flex_message = to_flex(md_text, link_mode="button")
|
|
73
|
+
|
|
74
|
+
# Send via LINE Messaging API SDK
|
|
75
|
+
request = PushMessageRequest(to="USER_ID", messages=[flex_message])
|
|
76
|
+
messaging_api.push_message(request)
|
|
77
|
+
````
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Options
|
|
82
|
+
|
|
83
|
+
### Link Modes (`link_mode`)
|
|
84
|
+
|
|
85
|
+
You can control how Markdown links `[Title](https://...)` are rendered in the Flex Message:
|
|
86
|
+
|
|
87
|
+
| Mode | Description |
|
|
88
|
+
| :--- | :--- |
|
|
89
|
+
| `button` **(Default)** | Appends interactive URI action buttons below the text block. |
|
|
90
|
+
| `action` | Renders links in text format: `Title (https://...)`. |
|
|
91
|
+
| `url_text` | Renders links in text format: `Title [https://...]`. |
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
flex_msg = to_flex(md_text, link_mode="button")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Development & Contributing
|
|
100
|
+
|
|
101
|
+
See [DEVELOPMENT.md](DEVELOPMENT.md) for local setup, running tests, and contribution guidelines.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
This project is licensed under the MIT License.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# md2lineflex - Markdown to LINE FlexMessage Converter
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/py/md2lineflex)
|
|
4
|
+
[](https://pypi.org/project/md2lineflex/)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
A lightweight Python library that converts **Markdown text into LINE Bot Flex Messages** effortlessly.
|
|
8
|
+
|
|
9
|
+
Designed especially for LLM / Chatbot developers who want to render Markdown outputs (from OpenAI, Claude, etc.) as beautiful, structured LINE Flex Messages instead of plain text.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- 🚀 **Zero-Config Conversion:** Simply pass a Markdown string and get a LINE Messaging API compatible `FlexMessage` object or `dict`.
|
|
16
|
+
- 🎨 **Rich Component Support:** Supports Headings, Paragraphs, Lists, Code Blocks, Quotes, Links, Images, and Tables.
|
|
17
|
+
- 🔗 **Flexible Link Handling:** Render links as embedded URLs, inline text, or interactive LINE URI Action Buttons.
|
|
18
|
+
- 🛡️ **Type Safe:** Fully typed with `py.typed` marker (PEP 561) for modern IDE auto-completion.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# Using uv (Recommended)
|
|
26
|
+
uv add md2lineflex
|
|
27
|
+
|
|
28
|
+
# Using pip
|
|
29
|
+
pip install md2lineflex
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Prerequisite: Python `>= 3.10` and `line-bot-sdk >= 3.0.0`
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
````python
|
|
39
|
+
from linebot.v3.messaging import PushMessageRequest
|
|
40
|
+
from md2lineflex import to_flex
|
|
41
|
+
|
|
42
|
+
md_text = """
|
|
43
|
+
## Hello LINE Bot!
|
|
44
|
+
|
|
45
|
+
This is **bold text** generated from Markdown.
|
|
46
|
+
|
|
47
|
+
- Feature 1: Automatic Flex Box layout
|
|
48
|
+
- Feature 2: Supports [Links](https://example.com)
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
print("Hello World")
|
|
52
|
+
```
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
# Convert Markdown to FlexMessage object
|
|
56
|
+
flex_message = to_flex(md_text, link_mode="button")
|
|
57
|
+
|
|
58
|
+
# Send via LINE Messaging API SDK
|
|
59
|
+
request = PushMessageRequest(to="USER_ID", messages=[flex_message])
|
|
60
|
+
messaging_api.push_message(request)
|
|
61
|
+
````
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Options
|
|
66
|
+
|
|
67
|
+
### Link Modes (`link_mode`)
|
|
68
|
+
|
|
69
|
+
You can control how Markdown links `[Title](https://...)` are rendered in the Flex Message:
|
|
70
|
+
|
|
71
|
+
| Mode | Description |
|
|
72
|
+
| :--- | :--- |
|
|
73
|
+
| `button` **(Default)** | Appends interactive URI action buttons below the text block. |
|
|
74
|
+
| `action` | Renders links in text format: `Title (https://...)`. |
|
|
75
|
+
| `url_text` | Renders links in text format: `Title [https://...]`. |
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
flex_msg = to_flex(md_text, link_mode="button")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Development & Contributing
|
|
84
|
+
|
|
85
|
+
See [DEVELOPMENT.md](DEVELOPMENT.md) for local setup, running tests, and contribution guidelines.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
This project is licensed under the MIT License.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "md2lineflex"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Markdown to LINE FlexMessage Converter"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
keywords = [
|
|
9
|
+
"markdown",
|
|
10
|
+
"LINE",
|
|
11
|
+
"flexmessage",
|
|
12
|
+
]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"line-bot-sdk>=3.25.0",
|
|
16
|
+
"mdformat>=1.0.0",
|
|
17
|
+
"mdformat-gfm>=1.0.0",
|
|
18
|
+
"mistletoe>=1.6.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[[project.authors]]
|
|
22
|
+
name = "parkwestlabs"
|
|
23
|
+
email = "281970052+parkwestlabs@users.noreply.github.com"
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["uv_build>=0.12.5,<0.13.0"]
|
|
27
|
+
build-backend = "uv_build"
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = [
|
|
31
|
+
"pytest>=9.1.1",
|
|
32
|
+
"pytest-cov>=7.1.0",
|
|
33
|
+
"ruff>=0.16.4",
|
|
34
|
+
"syrupy>=5.5.3",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.ruff.lint]
|
|
38
|
+
select = ["ALL"]
|
|
39
|
+
ignore = [
|
|
40
|
+
"D",
|
|
41
|
+
"TC",
|
|
42
|
+
"CPY",
|
|
43
|
+
"COM812",
|
|
44
|
+
"G004",
|
|
45
|
+
"INP001",
|
|
46
|
+
]
|
|
47
|
+
allowed-confusables = [
|
|
48
|
+
"(",
|
|
49
|
+
")",
|
|
50
|
+
":",
|
|
51
|
+
"?",
|
|
52
|
+
"!",
|
|
53
|
+
"&",
|
|
54
|
+
"+",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint.per-file-ignores]
|
|
58
|
+
"tests/**/*" = [
|
|
59
|
+
"S101",
|
|
60
|
+
"ANN201",
|
|
61
|
+
"PLR2004",
|
|
62
|
+
"ERA001",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
[tool.pytest.ini_options]
|
|
66
|
+
testpaths = ["tests"]
|
|
67
|
+
addopts = "--cov=src --cov-branch --cov-report=term-missing --cov-report=html"
|
|
68
|
+
|
|
69
|
+
[tool.coverage.report]
|
|
70
|
+
fail_under = 90
|
|
71
|
+
|
|
72
|
+
[tool.coverage.run]
|
|
73
|
+
omit = ["*/__init__.py"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "md2lineflex"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Markdown to LINE FlexMessage Converter"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
keywords = ["markdown", "LINE", "flexmessage"]
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "parkwestlabs", email = "281970052+parkwestlabs@users.noreply.github.com" }
|
|
11
|
+
]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"line-bot-sdk>=3.25.0",
|
|
15
|
+
"mdformat>=1.0.0",
|
|
16
|
+
"mdformat-gfm>=1.0.0",
|
|
17
|
+
"mistletoe>=1.6.0",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.12.5,<0.13.0"]
|
|
22
|
+
build-backend = "uv_build"
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = [
|
|
26
|
+
"pytest>=9.1.1",
|
|
27
|
+
"pytest-cov>=7.1.0",
|
|
28
|
+
"ruff>=0.16.4",
|
|
29
|
+
"syrupy>=5.5.3",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[tool.ruff.lint]
|
|
33
|
+
select = ["ALL"]
|
|
34
|
+
ignore = [
|
|
35
|
+
"D",
|
|
36
|
+
"TC",
|
|
37
|
+
"CPY",
|
|
38
|
+
"COM812",
|
|
39
|
+
"G004", # Logging statement uses f-string
|
|
40
|
+
"INP001", # an implicit namespace package
|
|
41
|
+
]
|
|
42
|
+
# RUF001, RUF002, RUF003 のすべてで「?」を無視する
|
|
43
|
+
allowed-confusables = ["(", ")", ":", "?", "!", "&", "+"]
|
|
44
|
+
|
|
45
|
+
[tool.ruff.lint.per-file-ignores]
|
|
46
|
+
"tests/**/*" = [
|
|
47
|
+
"S101", # Use of assert
|
|
48
|
+
"ANN201", # Missing return type annotation for public function
|
|
49
|
+
"PLR2004", # Magic value used in comparison
|
|
50
|
+
"ERA001", # Found commented-out code
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
testpaths = ["tests"]
|
|
55
|
+
addopts = "--cov=src --cov-branch --cov-report=term-missing --cov-report=html"
|
|
56
|
+
|
|
57
|
+
[tool.coverage.report]
|
|
58
|
+
fail_under = 90
|
|
59
|
+
|
|
60
|
+
[tool.coverage.run]
|
|
61
|
+
omit = [
|
|
62
|
+
"*/__init__.py",
|
|
63
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
from md2lineflex.md_to_flex import to_flex
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("md2lineflex")
|
|
7
|
+
except PackageNotFoundError:
|
|
8
|
+
# 開発中(未インストール状態)のフォールバック
|
|
9
|
+
__version__ = "0.0.0"
|
|
10
|
+
|
|
11
|
+
__all__ = ["__version__", "to_flex"]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
from linebot.v3.messaging import (
|
|
2
|
+
FlexBox,
|
|
3
|
+
FlexButton,
|
|
4
|
+
FlexComponent,
|
|
5
|
+
FlexImage,
|
|
6
|
+
FlexSeparator,
|
|
7
|
+
FlexSpan,
|
|
8
|
+
FlexText,
|
|
9
|
+
URIAction,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from md2lineflex.theme import EMPTY_CHAR, Color, Margin, Size
|
|
13
|
+
|
|
14
|
+
# 共通フォールバックオブジェクト
|
|
15
|
+
EMPTY_SPAN = FlexSpan(text=EMPTY_CHAR)
|
|
16
|
+
# contents に渡す用のリスト(読み取り専用想定)
|
|
17
|
+
EMPTY_SPANS = [EMPTY_SPAN]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def heading_component(text: str, level: int) -> FlexText:
|
|
21
|
+
# text が空文字や None だった場合に半角スペース1文字にフォールバック
|
|
22
|
+
safe_text = text if text and text.strip() else EMPTY_CHAR
|
|
23
|
+
|
|
24
|
+
size_map = {1: Size.HEADING_H1, 2: Size.HEADING_H2}
|
|
25
|
+
size = size_map.get(level, Size.BODY)
|
|
26
|
+
|
|
27
|
+
return FlexText(
|
|
28
|
+
text=safe_text,
|
|
29
|
+
weight="bold",
|
|
30
|
+
size=size,
|
|
31
|
+
color=Color.HEADING,
|
|
32
|
+
wrap=True,
|
|
33
|
+
margin=Margin.HEADING,
|
|
34
|
+
) # pyright: ignore[reportCallIssue]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def paragraph_component(spans: list[FlexSpan]) -> FlexText:
|
|
38
|
+
return FlexText(
|
|
39
|
+
size=Size.BODY,
|
|
40
|
+
wrap=True,
|
|
41
|
+
margin=Margin.PARAGRAPH,
|
|
42
|
+
color=Color.BODY,
|
|
43
|
+
contents=spans or EMPTY_SPANS,
|
|
44
|
+
) # pyright: ignore[reportCallIssue]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def code_block_component(raw_code: str) -> FlexBox:
|
|
48
|
+
return FlexBox(
|
|
49
|
+
layout="vertical",
|
|
50
|
+
backgroundColor=Color.BG_CODE_BLOCK,
|
|
51
|
+
cornerRadius="sm",
|
|
52
|
+
paddingAll="md",
|
|
53
|
+
margin=Margin.BLOCK,
|
|
54
|
+
contents=[
|
|
55
|
+
FlexText(
|
|
56
|
+
text=raw_code or EMPTY_CHAR,
|
|
57
|
+
size=Size.CODE,
|
|
58
|
+
color=Color.CODE_BLOCK_TEXT, # abb2bf
|
|
59
|
+
wrap=True,
|
|
60
|
+
) # pyright: ignore[reportCallIssue]
|
|
61
|
+
],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def list_item_component(
|
|
66
|
+
prefix: str,
|
|
67
|
+
spans: list[FlexSpan],
|
|
68
|
+
extras: list[FlexButton] | None = None,
|
|
69
|
+
) -> FlexBox:
|
|
70
|
+
# 行頭記号用テキスト
|
|
71
|
+
prefix_component = FlexText(
|
|
72
|
+
text=prefix or EMPTY_CHAR,
|
|
73
|
+
size=Size.BODY,
|
|
74
|
+
color=Color.MUTED,
|
|
75
|
+
flex=0, # 幅固定
|
|
76
|
+
) # pyright: ignore[reportCallIssue]
|
|
77
|
+
|
|
78
|
+
# 本文用テキスト (contents に spans を配置)
|
|
79
|
+
text_component = FlexText(
|
|
80
|
+
text=EMPTY_CHAR, # デフォルト
|
|
81
|
+
size=Size.BODY,
|
|
82
|
+
color=Color.BODY,
|
|
83
|
+
wrap=True,
|
|
84
|
+
flex=1, # 残りの幅を占有
|
|
85
|
+
contents=spans or EMPTY_SPANS,
|
|
86
|
+
) # pyright: ignore[reportCallIssue]
|
|
87
|
+
|
|
88
|
+
row_box = FlexBox(
|
|
89
|
+
layout="baseline",
|
|
90
|
+
spacing="sm",
|
|
91
|
+
contents=[prefix_component, text_component],
|
|
92
|
+
) # pyright: ignore[reportCallIssue]
|
|
93
|
+
|
|
94
|
+
item_contents: list[FlexComponent] = [row_box]
|
|
95
|
+
|
|
96
|
+
# リンクボタン等の追加要素(extras)があれば下部に追加
|
|
97
|
+
if extras:
|
|
98
|
+
item_contents.extend(extras)
|
|
99
|
+
|
|
100
|
+
return FlexBox(
|
|
101
|
+
layout="vertical",
|
|
102
|
+
spacing="xs",
|
|
103
|
+
margin=Margin.LIST_ITEM,
|
|
104
|
+
contents=item_contents,
|
|
105
|
+
) # pyright: ignore[reportCallIssue]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def quote_box_component(contents: list[FlexComponent]) -> FlexBox:
|
|
109
|
+
# 引用テキストにスタイル (斜体・グレー色) を適用
|
|
110
|
+
for item in contents:
|
|
111
|
+
if isinstance(item, FlexText):
|
|
112
|
+
item.style = "italic"
|
|
113
|
+
item.color = Color.QUOTE
|
|
114
|
+
|
|
115
|
+
# contents が万が一空の場合は空のテキストコンポーネントを補填 (None 回避)
|
|
116
|
+
if not contents:
|
|
117
|
+
empty_text = FlexText(
|
|
118
|
+
size=Size.BODY,
|
|
119
|
+
style="italic",
|
|
120
|
+
color=Color.QUOTE,
|
|
121
|
+
wrap=True,
|
|
122
|
+
contents=EMPTY_SPANS,
|
|
123
|
+
) # pyright: ignore[reportCallIssue]
|
|
124
|
+
contents = [empty_text]
|
|
125
|
+
|
|
126
|
+
# 引用デザインを適用した FlexBox
|
|
127
|
+
return FlexBox(
|
|
128
|
+
type="box",
|
|
129
|
+
layout="vertical",
|
|
130
|
+
spacing="xs",
|
|
131
|
+
backgroundColor=Color.BG_QUOTE,
|
|
132
|
+
borderColor=Color.BORDER,
|
|
133
|
+
borderWidth="semi-bold",
|
|
134
|
+
paddingAll="md",
|
|
135
|
+
margin=Margin.BLOCK,
|
|
136
|
+
contents=contents, # 絶対に None にならない list
|
|
137
|
+
) # pyright: ignore[reportCallIssue]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def link_button_component(link_text: str, target_url: str) -> FlexButton:
|
|
141
|
+
return FlexButton(
|
|
142
|
+
style="secondary",
|
|
143
|
+
height="sm",
|
|
144
|
+
margin="sm",
|
|
145
|
+
action=URIAction(
|
|
146
|
+
type="uri",
|
|
147
|
+
label=f"開く: {link_text[:12]}",
|
|
148
|
+
uri=target_url,
|
|
149
|
+
), # pyright: ignore[reportCallIssue]
|
|
150
|
+
) # pyright: ignore[reportCallIssue]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def image_component(url: str) -> FlexImage:
|
|
154
|
+
return FlexImage(
|
|
155
|
+
url=url,
|
|
156
|
+
size="full",
|
|
157
|
+
aspectMode="fit",
|
|
158
|
+
margin=Margin.BLOCK,
|
|
159
|
+
) # pyright: ignore[reportCallIssue]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def separator_component() -> FlexSeparator:
|
|
163
|
+
return FlexSeparator(margin=Margin.HEADING, color=Color.SEPARATOR)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def table_cell_component(
|
|
167
|
+
spans: list[FlexSpan], flex: int = 1, *, is_header: bool = False
|
|
168
|
+
) -> FlexBox:
|
|
169
|
+
"""表の 1 セルを生成 (ヘッダーの場合は太字・背景色を設定)"""
|
|
170
|
+
# ヘッダーの場合は文字を太字に補正(個別指定がなければ)
|
|
171
|
+
if is_header:
|
|
172
|
+
for span in spans:
|
|
173
|
+
if not span.weight:
|
|
174
|
+
span.weight = "bold"
|
|
175
|
+
|
|
176
|
+
text_comp = FlexText(
|
|
177
|
+
type="text",
|
|
178
|
+
contents=spans or EMPTY_SPANS,
|
|
179
|
+
size=Size.TABLE_CELL,
|
|
180
|
+
color=Color.HEADING if is_header else Color.BODY,
|
|
181
|
+
wrap=True,
|
|
182
|
+
flex=flex,
|
|
183
|
+
) # pyright: ignore[reportCallIssue]
|
|
184
|
+
|
|
185
|
+
return FlexBox(
|
|
186
|
+
layout="vertical",
|
|
187
|
+
contents=[text_comp],
|
|
188
|
+
flex=flex,
|
|
189
|
+
paddingAll="xs",
|
|
190
|
+
backgroundColor=Color.BG_TABLE_HEADER if is_header else Color.BG_TABLE_BODY,
|
|
191
|
+
) # pyright: ignore[reportCallIssue]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def table_row_component(cells: list[FlexBox], *, is_header: bool = False) -> FlexBox:
|
|
195
|
+
"""表の 1 行を横並び (horizontal) で生成"""
|
|
196
|
+
return FlexBox(
|
|
197
|
+
layout="horizontal",
|
|
198
|
+
contents=cells,
|
|
199
|
+
spacing="xs",
|
|
200
|
+
borderWidth="1px" if is_header else "none",
|
|
201
|
+
borderColor=Color.BORDER,
|
|
202
|
+
) # pyright: ignore[reportCallIssue]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def table_component(rows: list[FlexBox]) -> FlexBox:
|
|
206
|
+
"""表全体のコンテナを生成"""
|
|
207
|
+
return FlexBox(
|
|
208
|
+
layout="vertical",
|
|
209
|
+
contents=rows,
|
|
210
|
+
spacing="2px", # 行間の微小スキマ(区切り線っぽく見せる効果)
|
|
211
|
+
margin=Margin.BLOCK,
|
|
212
|
+
backgroundColor=Color.BG_TABLE_BORDER, # 背景色をグレーにして枠線効果を出す
|
|
213
|
+
paddingAll="1px",
|
|
214
|
+
) # pyright: ignore[reportCallIssue]
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from linebot.v3.messaging import (
|
|
5
|
+
FlexBox,
|
|
6
|
+
FlexBubble,
|
|
7
|
+
FlexButton,
|
|
8
|
+
FlexComponent,
|
|
9
|
+
FlexSpan,
|
|
10
|
+
FlexText,
|
|
11
|
+
)
|
|
12
|
+
from mistletoe.base_renderer import BaseRenderer
|
|
13
|
+
from mistletoe.block_token import (
|
|
14
|
+
BlockCode,
|
|
15
|
+
CodeFence,
|
|
16
|
+
Document,
|
|
17
|
+
Heading,
|
|
18
|
+
List,
|
|
19
|
+
ListItem,
|
|
20
|
+
Paragraph,
|
|
21
|
+
Quote,
|
|
22
|
+
Table,
|
|
23
|
+
TableCell,
|
|
24
|
+
TableRow,
|
|
25
|
+
ThematicBreak,
|
|
26
|
+
)
|
|
27
|
+
from mistletoe.span_token import (
|
|
28
|
+
Emphasis,
|
|
29
|
+
HtmlSpan,
|
|
30
|
+
Image,
|
|
31
|
+
InlineCode,
|
|
32
|
+
LineBreak,
|
|
33
|
+
Link,
|
|
34
|
+
RawText,
|
|
35
|
+
Strikethrough,
|
|
36
|
+
Strong,
|
|
37
|
+
)
|
|
38
|
+
from mistletoe.token import Token
|
|
39
|
+
|
|
40
|
+
from md2lineflex.flex_components import (
|
|
41
|
+
code_block_component,
|
|
42
|
+
heading_component,
|
|
43
|
+
image_component,
|
|
44
|
+
link_button_component,
|
|
45
|
+
list_item_component,
|
|
46
|
+
paragraph_component,
|
|
47
|
+
quote_box_component,
|
|
48
|
+
separator_component,
|
|
49
|
+
table_cell_component,
|
|
50
|
+
table_component,
|
|
51
|
+
table_row_component,
|
|
52
|
+
)
|
|
53
|
+
from md2lineflex.str_utils import to_json_utf8
|
|
54
|
+
|
|
55
|
+
LinkMode = Literal["url_text", "action", "button"]
|
|
56
|
+
|
|
57
|
+
# content 属性を持つ末端トークンの型定義 (型エイリアス)
|
|
58
|
+
LeafToken = RawText | LineBreak | HtmlSpan
|
|
59
|
+
|
|
60
|
+
# LINE Flex Message の構造
|
|
61
|
+
# - ブロック要素: FlexBox や FlexBubble(レイアウトの入れ物)
|
|
62
|
+
# - インライン要素: FlexText の中に入れる FlexSpan(文字装飾)
|
|
63
|
+
|
|
64
|
+
# mistletoe → LINE Flex Message
|
|
65
|
+
# Paragraph, Heading, List 等:
|
|
66
|
+
# render_* で受けて FlexComponent を組み立てる
|
|
67
|
+
# Strong, Emphasis, Link 等:
|
|
68
|
+
# _parse_span_tokens と _convert_child_to_span で一括して FlexSpan 群に変換する
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class FlexMessageRenderer(BaseRenderer):
|
|
72
|
+
"""Markdown AST を LINE Flex Message (Bubble) 用の JSON 文字列に変換するRenderer"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, link_mode: LinkMode = "url_text") -> None:
|
|
75
|
+
super().__init__()
|
|
76
|
+
self.link_mode: LinkMode = link_mode
|
|
77
|
+
|
|
78
|
+
# --- インライン要素 (spans & extra components) の抽出 ---
|
|
79
|
+
|
|
80
|
+
def _parse_span_tokens(
|
|
81
|
+
self, token: Token
|
|
82
|
+
) -> tuple[list[FlexSpan], list[FlexButton]]:
|
|
83
|
+
"""子要素から Flex Text 用の spans と、追加のコンポーネント (ボタン等) を抽出"""
|
|
84
|
+
spans: list[FlexSpan] = []
|
|
85
|
+
extras: list[FlexButton] = []
|
|
86
|
+
|
|
87
|
+
for child in token.children or []:
|
|
88
|
+
span = self._convert_child_to_span(child, self.link_mode)
|
|
89
|
+
if span:
|
|
90
|
+
spans.append(span)
|
|
91
|
+
|
|
92
|
+
# リンクボタン (extra) の抽出 (Link かつ button モード時のみ)
|
|
93
|
+
if isinstance(child, Link) and self.link_mode == "button":
|
|
94
|
+
link_text = self._get_plain_text(child)
|
|
95
|
+
link_button = link_button_component(link_text, child.target)
|
|
96
|
+
extras.append(link_button)
|
|
97
|
+
|
|
98
|
+
# Paragraph やその他の容器トークンの場合は再帰的に子要素を解析
|
|
99
|
+
elif child.children:
|
|
100
|
+
sub_spans, sub_extras = self._parse_span_tokens(child)
|
|
101
|
+
spans.extend(sub_spans)
|
|
102
|
+
extras.extend(sub_extras)
|
|
103
|
+
|
|
104
|
+
# その他の未対応インライン要素(安全策)
|
|
105
|
+
else:
|
|
106
|
+
fallback_text = self._get_plain_text(child)
|
|
107
|
+
if fallback_text:
|
|
108
|
+
spans.append(FlexSpan(text=fallback_text))
|
|
109
|
+
|
|
110
|
+
return spans, extras
|
|
111
|
+
|
|
112
|
+
def _convert_child_to_span( # noqa: PLR0911
|
|
113
|
+
self, child: Token, link_mode: LinkMode
|
|
114
|
+
) -> FlexSpan | None:
|
|
115
|
+
text = self._get_plain_text(child)
|
|
116
|
+
|
|
117
|
+
match child:
|
|
118
|
+
case RawText():
|
|
119
|
+
# 空文字の場合は半角スペースにして LINE 仕様違反を防ぐ
|
|
120
|
+
return FlexSpan(text=child.content or " ")
|
|
121
|
+
case LineBreak():
|
|
122
|
+
return FlexSpan(text="\n")
|
|
123
|
+
case Strong(): # 太字
|
|
124
|
+
return FlexSpan(text=text, weight="bold")
|
|
125
|
+
case Emphasis(): # 斜体
|
|
126
|
+
return FlexSpan(text=text, style="italic", color="#555555")
|
|
127
|
+
case Strikethrough():
|
|
128
|
+
return FlexSpan(text=text, decoration="line-through")
|
|
129
|
+
case InlineCode(): # 行内コード
|
|
130
|
+
return FlexSpan(text=f" {text} ", color="#0052cc", weight="bold")
|
|
131
|
+
case Link(): # リンク ([text](url))
|
|
132
|
+
if link_mode == "action":
|
|
133
|
+
return FlexSpan(text=f"{text} ({child.target})", color="#06c755", weight="bold") # fmt: skip # noqa: E501
|
|
134
|
+
if link_mode == "button":
|
|
135
|
+
return FlexSpan(text=text, weight="bold", color="#06c755")
|
|
136
|
+
return FlexSpan(text=f"{text} [{child.target}]")
|
|
137
|
+
case _:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
def _get_plain_text(self, token: Token) -> str:
|
|
141
|
+
"""純粋な文字列だけを取り出すヘルパー"""
|
|
142
|
+
|
|
143
|
+
# 1. content 属性を持つ末端ノードなら文字列を返す
|
|
144
|
+
if isinstance(token, LeafToken):
|
|
145
|
+
return token.content
|
|
146
|
+
|
|
147
|
+
# 2. children を持つ容器ノードなら子要素をたどる (再帰処理)
|
|
148
|
+
if token.children is not None:
|
|
149
|
+
return "".join(self._get_plain_text(child) for child in token.children)
|
|
150
|
+
|
|
151
|
+
# 3. Image などの特殊要素、又は children が None の場合はテキストなし (空文字)
|
|
152
|
+
return ""
|
|
153
|
+
|
|
154
|
+
# -------------------------------------------------------------------------
|
|
155
|
+
# Helper Methods (共通処理)
|
|
156
|
+
# -------------------------------------------------------------------------
|
|
157
|
+
def _render_children(self, token: Token) -> list[FlexComponent]:
|
|
158
|
+
"""
|
|
159
|
+
子トークン群をレンダリングし、FlexComponent オブジェクトのリストとして集約する
|
|
160
|
+
"""
|
|
161
|
+
contents: list[FlexComponent] = []
|
|
162
|
+
|
|
163
|
+
for child in token.children or []:
|
|
164
|
+
rendered_str = self.render(child)
|
|
165
|
+
if not rendered_str or not rendered_str.strip():
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
parsed = json.loads(rendered_str.strip())
|
|
169
|
+
|
|
170
|
+
# 単体要素をリストに統一してイテレート処理を共通化
|
|
171
|
+
items = parsed if isinstance(parsed, list) else [parsed]
|
|
172
|
+
|
|
173
|
+
contents.extend([FlexComponent.from_dict(item) for item in items])
|
|
174
|
+
|
|
175
|
+
return contents
|
|
176
|
+
|
|
177
|
+
# -------------------------------------------------------------------------
|
|
178
|
+
# Block Renderers (ブロック要素のレンダリング)
|
|
179
|
+
# -------------------------------------------------------------------------
|
|
180
|
+
def render_document(self, token: Document) -> str:
|
|
181
|
+
contents = self._render_children(token)
|
|
182
|
+
|
|
183
|
+
# ドキュメントが空の場合の最小表示保証
|
|
184
|
+
if not contents:
|
|
185
|
+
# 空の Markdown の場合は安全なプレースホルダーテキストを設置
|
|
186
|
+
contents = [heading_component(" ", 3)]
|
|
187
|
+
|
|
188
|
+
# FlexBox と FlexBubble のインスタンスを from_dict で生成する
|
|
189
|
+
body_box = FlexBox(
|
|
190
|
+
layout="vertical",
|
|
191
|
+
spacing="md",
|
|
192
|
+
contents=contents,
|
|
193
|
+
) # pyright: ignore[reportCallIssue]
|
|
194
|
+
|
|
195
|
+
bubble = FlexBubble(body=body_box)
|
|
196
|
+
|
|
197
|
+
return to_json_utf8(bubble.to_dict())
|
|
198
|
+
|
|
199
|
+
def render_heading(self, token: Heading) -> str:
|
|
200
|
+
text = self._get_plain_text(token)
|
|
201
|
+
heading = heading_component(text, token.level)
|
|
202
|
+
return to_json_utf8(heading.to_dict())
|
|
203
|
+
|
|
204
|
+
def render_paragraph(self, token: Paragraph) -> str:
|
|
205
|
+
children = list(token.children or [])
|
|
206
|
+
|
|
207
|
+
# 段落内に画像単体のみが含まれる場合の判定
|
|
208
|
+
if len(children) == 1 and isinstance(children[0], Image):
|
|
209
|
+
img_token = children[0]
|
|
210
|
+
image = image_component(img_token.src)
|
|
211
|
+
return to_json_utf8(image.to_dict())
|
|
212
|
+
|
|
213
|
+
spans, extras = self._parse_span_tokens(token)
|
|
214
|
+
|
|
215
|
+
# 段落テキスト(FlexText オブジェクト)を作成
|
|
216
|
+
para = paragraph_component(spans)
|
|
217
|
+
|
|
218
|
+
# 段落本体と追加のリンクボタン群をまとめる(すべて FlexComponent)
|
|
219
|
+
components: list[FlexComponent] = [para, *extras]
|
|
220
|
+
|
|
221
|
+
# リストを JSON 化して返却
|
|
222
|
+
return to_json_utf8([c.to_dict() for c in components])
|
|
223
|
+
|
|
224
|
+
def render_list(self, token: List) -> str:
|
|
225
|
+
"""リスト全体をレンダリング"""
|
|
226
|
+
list_boxes: list[FlexComponent] = []
|
|
227
|
+
|
|
228
|
+
# 連番管理用の状態フラグとカウンター
|
|
229
|
+
is_ordered: bool | None = None
|
|
230
|
+
current_num = 0
|
|
231
|
+
|
|
232
|
+
# 最初の ListItem の leader (例: "1.", "-", "*") から番号付きリストか判定
|
|
233
|
+
for child in token.children or []:
|
|
234
|
+
if not isinstance(child, ListItem):
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
if is_ordered is None:
|
|
238
|
+
leader = child.leader
|
|
239
|
+
clean_leader = leader.strip().rstrip(".")
|
|
240
|
+
|
|
241
|
+
# プレフィックスの先頭が数字(例: "1")なら番号付きリストと判定
|
|
242
|
+
is_ordered = clean_leader.isdigit()
|
|
243
|
+
if is_ordered:
|
|
244
|
+
current_num = int(clean_leader)
|
|
245
|
+
|
|
246
|
+
# プレフィックスの生成
|
|
247
|
+
if is_ordered:
|
|
248
|
+
prefix = f"{current_num}."
|
|
249
|
+
current_num += 1 # 2, 3... と自動インクリメント
|
|
250
|
+
else:
|
|
251
|
+
prefix = "•"
|
|
252
|
+
|
|
253
|
+
# 子要素 (インライン装飾) の解析
|
|
254
|
+
item_json = self.render_list_item(child, prefix)
|
|
255
|
+
item_dict = json.loads(item_json)
|
|
256
|
+
list_boxes.append(FlexComponent.from_dict(item_dict))
|
|
257
|
+
|
|
258
|
+
list_container = FlexBox(
|
|
259
|
+
layout="vertical",
|
|
260
|
+
spacing="xs",
|
|
261
|
+
margin="md",
|
|
262
|
+
contents=list_boxes,
|
|
263
|
+
) # pyright: ignore[reportCallIssue]
|
|
264
|
+
return to_json_utf8(list_container.to_dict())
|
|
265
|
+
|
|
266
|
+
def render_list_item(self, token: ListItem, prefix: str = "•") -> str:
|
|
267
|
+
"""リスト項目 1 つを JSON 文字列としてレンダリング"""
|
|
268
|
+
spans: list[FlexSpan] = []
|
|
269
|
+
extras: list[FlexButton] = []
|
|
270
|
+
|
|
271
|
+
# ListItem の子要素(Paragraph や RawText 等)から Span と Extras を抽出
|
|
272
|
+
for child in token.children or []:
|
|
273
|
+
child_spans, child_extras = self._parse_span_tokens(child)
|
|
274
|
+
spans.extend(child_spans)
|
|
275
|
+
extras.extend(child_extras)
|
|
276
|
+
|
|
277
|
+
list_item = list_item_component(prefix, spans, extras)
|
|
278
|
+
|
|
279
|
+
return to_json_utf8(list_item.to_dict())
|
|
280
|
+
|
|
281
|
+
def render_block_code(self, token: CodeFence | BlockCode) -> str:
|
|
282
|
+
"""CodeFence と BlockCode の両方がここにルーティングされる"""
|
|
283
|
+
raw_code = self._get_plain_text(token).strip()
|
|
284
|
+
code_block = code_block_component(raw_code)
|
|
285
|
+
return to_json_utf8(code_block.to_dict())
|
|
286
|
+
|
|
287
|
+
def render_quote(self, token: Quote) -> str:
|
|
288
|
+
"""引用 (>) ブロックの描画"""
|
|
289
|
+
contents = self._render_children(token)
|
|
290
|
+
|
|
291
|
+
# 引用ブロック内の全テキスト要素の上マージンを無効化して余白重複を防ぐ
|
|
292
|
+
for item in contents:
|
|
293
|
+
if isinstance(item, FlexText):
|
|
294
|
+
item.margin = "none"
|
|
295
|
+
|
|
296
|
+
quote_box = quote_box_component(contents)
|
|
297
|
+
return to_json_utf8(quote_box.to_dict())
|
|
298
|
+
|
|
299
|
+
def render_thematic_break(self, token: ThematicBreak) -> str: # noqa: ARG002
|
|
300
|
+
"""水平線 (---)"""
|
|
301
|
+
separator = separator_component()
|
|
302
|
+
return to_json_utf8(separator.to_dict())
|
|
303
|
+
|
|
304
|
+
def render_table(self, token: Table) -> str:
|
|
305
|
+
"""Markdown Table を FlexBox のグリッドレイアウトとして描画"""
|
|
306
|
+
row_boxes: list[FlexBox] = []
|
|
307
|
+
|
|
308
|
+
# ヘッダー行 (token.header) の処理
|
|
309
|
+
if token.header is not None:
|
|
310
|
+
header_box = self._render_table_row(token.header, is_header=True)
|
|
311
|
+
if header_box:
|
|
312
|
+
row_boxes.append(header_box)
|
|
313
|
+
|
|
314
|
+
# 本文データ行 (token.children) の処理
|
|
315
|
+
for child in token.children or []:
|
|
316
|
+
# Table の children には必ず TableRow が入っているはず
|
|
317
|
+
if not isinstance(child, TableRow):
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
row_box = self._render_table_row(child, is_header=False)
|
|
321
|
+
if row_box:
|
|
322
|
+
row_boxes.append(row_box)
|
|
323
|
+
|
|
324
|
+
# 表全体 Component の作成
|
|
325
|
+
table_container = table_component(row_boxes)
|
|
326
|
+
return to_json_utf8(table_container.to_dict())
|
|
327
|
+
|
|
328
|
+
def _render_table_row(self, row: TableRow, *, is_header: bool) -> FlexBox | None:
|
|
329
|
+
cell_boxes: list[FlexBox] = []
|
|
330
|
+
|
|
331
|
+
for cell in row.children or []:
|
|
332
|
+
if isinstance(cell, TableCell):
|
|
333
|
+
spans, _ = self._parse_span_tokens(cell)
|
|
334
|
+
if not spans:
|
|
335
|
+
spans = [FlexSpan(text=" ")] # 空セル対策
|
|
336
|
+
cell_boxes.append(table_cell_component(spans, is_header=is_header))
|
|
337
|
+
|
|
338
|
+
if not cell_boxes:
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
return table_row_component(cell_boxes, is_header=is_header)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
import mdformat
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def clean_llm_output(text: str) -> str:
|
|
7
|
+
# 1. 太字のアスタリスクを除去
|
|
8
|
+
no_bold = re.sub(r"\*\*(.*?)\*\*", r"\1", text)
|
|
9
|
+
|
|
10
|
+
# 2. リストやテーブルを含めて綺麗なMarkdownに整形
|
|
11
|
+
formatted_text = mdformat.text(no_bold, extensions=["gfm"])
|
|
12
|
+
|
|
13
|
+
# 3. チャットUI用に末尾の改行を除去する
|
|
14
|
+
return formatted_text.rstrip("\n")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from linebot.v3.messaging import FlexMessage
|
|
4
|
+
from mistletoe.block_token import Document
|
|
5
|
+
|
|
6
|
+
from md2lineflex.flex_message_renderer import FlexMessageRenderer, LinkMode
|
|
7
|
+
from md2lineflex.llm_utils import clean_llm_output
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def to_flex(md_text: str, link_mode: LinkMode = "button") -> FlexMessage:
|
|
11
|
+
"""
|
|
12
|
+
統合型 Markdown -> LINE SDK FlexMessage 変換関数
|
|
13
|
+
|
|
14
|
+
1. Markdown text から FlexMessage オブジェクトを生成
|
|
15
|
+
2. LINE Bot SDK で送信
|
|
16
|
+
(flex_msg は linebot.v3.messaging.FlexMessage と互換性があります)
|
|
17
|
+
|
|
18
|
+
Example:
|
|
19
|
+
```python
|
|
20
|
+
flex_msg = to_flex(md_text)
|
|
21
|
+
messaging_api.push_message(
|
|
22
|
+
PushMessageRequest(
|
|
23
|
+
to="USER_ID",
|
|
24
|
+
messages=[flex_msg]
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
```
|
|
28
|
+
"""
|
|
29
|
+
alt_text = clean_llm_output(md_text)
|
|
30
|
+
|
|
31
|
+
with FlexMessageRenderer(link_mode) as renderer:
|
|
32
|
+
# Document 全体の Bubble 構造(JSON文字列)を取得
|
|
33
|
+
json_str = renderer.render(Document(md_text.splitlines(keepends=True)))
|
|
34
|
+
bubble_dict = json.loads(json_str)
|
|
35
|
+
|
|
36
|
+
# FlexMessage 全体を dict 形式で組み立てて from_dict で変換する
|
|
37
|
+
# altText フィールドは 最大 400 文字らしい
|
|
38
|
+
flex_message_dict = {
|
|
39
|
+
"type": "flex",
|
|
40
|
+
"altText": alt_text.strip()[:400],
|
|
41
|
+
"contents": bubble_dict,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# from_dict を使うことで Pylance の引数不足エラーを回避
|
|
45
|
+
return FlexMessage.from_dict(flex_message_dict)
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""LINE Flex Message 用のテーマ・デザイン定数"""
|
|
2
|
+
|
|
3
|
+
# ------------------------------------------------------------------------------
|
|
4
|
+
# フォールバック定数
|
|
5
|
+
# ------------------------------------------------------------------------------
|
|
6
|
+
# LINE Flex Message 仕様(text の空文字禁止)を満たすための共通フォールバック定数
|
|
7
|
+
EMPTY_CHAR = " "
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# ------------------------------------------------------------------------------
|
|
11
|
+
# カラーパレット (Color Palette)
|
|
12
|
+
# ------------------------------------------------------------------------------
|
|
13
|
+
class Color:
|
|
14
|
+
# テキストカラー
|
|
15
|
+
HEADING = "#111111" # 見出し・ヘッダー用(濃い黒)
|
|
16
|
+
BODY = "#333333" # 本文・標準テキスト用
|
|
17
|
+
MUTED = "#888888" # 補足・リスト記号用
|
|
18
|
+
QUOTE = "#555555" # 引用テキスト用
|
|
19
|
+
CODE_INLINE = "#0052cc" # インラインコード用 (Jira風アクセントブルー)
|
|
20
|
+
CODE_BLOCK_TEXT = "#f8f8f2" # コードブロック文字色 (Monokai風)
|
|
21
|
+
|
|
22
|
+
# 背景色
|
|
23
|
+
BG_QUOTE = "#f8f9fa" # 引用ブロック背景色
|
|
24
|
+
BG_CODE_BLOCK = "#272822" # コードブロック背景色
|
|
25
|
+
BG_TABLE_HEADER = "#f0f0f0" # 表ヘッダー背景色
|
|
26
|
+
BG_TABLE_BODY = "#ffffff" # 表セル背景色
|
|
27
|
+
BG_TABLE_BORDER = "#e0e0e0" # 表の外枠・区切り色
|
|
28
|
+
|
|
29
|
+
# ボーダー・区切り線
|
|
30
|
+
BORDER = "#cccccc"
|
|
31
|
+
SEPARATOR = "#e0e0e0"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ------------------------------------------------------------------------------
|
|
35
|
+
# レイアウト・スタイル定数
|
|
36
|
+
# ------------------------------------------------------------------------------
|
|
37
|
+
class Size:
|
|
38
|
+
HEADING_H1 = "xl"
|
|
39
|
+
HEADING_H2 = "lg"
|
|
40
|
+
BODY = "sm"
|
|
41
|
+
CODE = "xs"
|
|
42
|
+
TABLE_CELL = "xs"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Margin:
|
|
46
|
+
HEADING = "lg"
|
|
47
|
+
PARAGRAPH = "md"
|
|
48
|
+
LIST_ITEM = "sm"
|
|
49
|
+
BLOCK = "md"
|