nonebot-plugin-receipts 0.2.0__tar.gz → 0.2.2__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.
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/PKG-INFO +49 -17
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/README.md +48 -16
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/command_handlers.py +2 -6
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/escpos_backend.py +40 -73
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts.egg-info/PKG-INFO +49 -17
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/pyproject.toml +1 -1
- nonebot_plugin_receipts-0.2.2/tests/test_renderer.py +125 -0
- nonebot_plugin_receipts-0.2.0/tests/test_renderer.py +0 -60
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/__init__.py +0 -0
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/config.py +0 -0
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/raster_backend.py +0 -0
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/render_blocks.py +0 -0
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/render_types.py +0 -0
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/render_utils.py +0 -0
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/renderer.py +0 -0
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/spooler.py +0 -0
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/template.py +0 -0
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/text_markup.py +0 -0
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts.egg-info/SOURCES.txt +0 -0
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts.egg-info/dependency_links.txt +0 -0
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts.egg-info/requires.txt +0 -0
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts.egg-info/top_level.txt +0 -0
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/setup.cfg +0 -0
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/tests/test_command_handlers.py +0 -0
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/tests/test_render_blocks.py +0 -0
- {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/tests/test_text_markup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nonebot_plugin_receipts
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: NoneBot2 plugin for sending text and image messages to receipts-spooler
|
|
5
5
|
Project-URL: homepage, https://github.com/Effect-Wei/nonebot_plugin_receipts
|
|
6
6
|
Project-URL: repository, https://github.com/Effect-Wei/nonebot_plugin_receipts
|
|
@@ -46,22 +46,22 @@ nonebot.load_plugin("nonebot_plugin_receipts")
|
|
|
46
46
|
|
|
47
47
|
插件通过 NoneBot 全局配置读取以下环境变量:
|
|
48
48
|
|
|
49
|
-
| 变量
|
|
50
|
-
|
|
|
51
|
-
| `RECEIPTS_SPOOLER_URL`
|
|
52
|
-
| `RECEIPTS_SPOOLER_TOKEN`
|
|
53
|
-
| `RECEIPTS_SPOOLER_TIMEOUT`
|
|
54
|
-
| `RECEIPT_IMAGE_FETCH_TIMEOUT`
|
|
55
|
-
| `RECEIPT_RENDER_MODE`
|
|
56
|
-
| `RECEIPT_PRINTER_WIDTH`
|
|
57
|
-
| `RECEIPT_TEMPLATE_PATH`
|
|
58
|
-
| `RECEIPT_FONT_PATH`
|
|
59
|
-
| `RECEIPT_FONT_SIZE`
|
|
60
|
-
| `RECEIPT_LINE_SPACING`
|
|
61
|
-
| `RECEIPT_SECTION_GAP`
|
|
62
|
-
| `RECEIPT_SESSION_TIMEOUT_SECONDS` | `120`
|
|
63
|
-
| `RECEIPT_FEED_LINES`
|
|
64
|
-
| `RECEIPT_ENABLE_CUT`
|
|
49
|
+
| 变量 | 默认值 | 说明 |
|
|
50
|
+
| --------------------------------- | ----------------------- | ------------------------------------- |
|
|
51
|
+
| `RECEIPTS_SPOOLER_URL` | `http://127.0.0.1:8000` | `receipts-spooler` 服务地址 |
|
|
52
|
+
| `RECEIPTS_SPOOLER_TOKEN` | 空 | 对应 `X-Spooler-Token` |
|
|
53
|
+
| `RECEIPTS_SPOOLER_TIMEOUT` | `5.0` | 调用 spooler 的 HTTP 超时 |
|
|
54
|
+
| `RECEIPT_IMAGE_FETCH_TIMEOUT` | `5.0` | 下载图片消息内容时的 HTTP 超时 |
|
|
55
|
+
| `RECEIPT_RENDER_MODE` | `raster` | 渲染模式,可选 `raster`/`hybrid` |
|
|
56
|
+
| `RECEIPT_PRINTER_WIDTH` | `576` | 小票像素宽度,最大 `576` |
|
|
57
|
+
| `RECEIPT_TEMPLATE_PATH` | 空 | 可选,指向渲染模板 JSON 文件 |
|
|
58
|
+
| `RECEIPT_FONT_PATH` | 空 | 可选,建议配置支持中文的 TTF/OTF 字体 |
|
|
59
|
+
| `RECEIPT_FONT_SIZE` | `24` | 文本字号 |
|
|
60
|
+
| `RECEIPT_LINE_SPACING` | `6` | 文本行距 |
|
|
61
|
+
| `RECEIPT_SECTION_GAP` | `6` | 文本和图片之间的垂直间距 |
|
|
62
|
+
| `RECEIPT_SESSION_TIMEOUT_SECONDS` | `120` | 等待用户补发打印内容的超时时间(秒) |
|
|
63
|
+
| `RECEIPT_FEED_LINES` | `4` | 打印后走纸行数 |
|
|
64
|
+
| `RECEIPT_ENABLE_CUT` | `true` | 是否附加切纸命令 |
|
|
65
65
|
|
|
66
66
|
### 渲染模式
|
|
67
67
|
|
|
@@ -138,6 +138,38 @@ nonebot.load_plugin("nonebot_plugin_receipts")
|
|
|
138
138
|
在 `raster` 模式下会使用更大的字号和额外留白;在 `hybrid` 模式下会尽量映射为 ESC/POS 的加粗和放大文本。
|
|
139
139
|
如果需要打印以 `#` 开头的普通文本,可以写成 `\# 普通文本`。
|
|
140
140
|
|
|
141
|
+
## 手动实机测试(不启动 NoneBot)
|
|
142
|
+
|
|
143
|
+
可直接运行脚本把测试内容渲染为 ESC/POS 并提交到 `receipts-spooler`:
|
|
144
|
+
|
|
145
|
+
```powershell
|
|
146
|
+
python scripts/manual_print_test.py --mode hybrid --spooler-url http://127.0.0.1:8000
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
常用参数:
|
|
150
|
+
|
|
151
|
+
- `--mode hybrid|raster`:渲染模式,默认 `hybrid`
|
|
152
|
+
- `--text`:测试文本(支持 `#` 标题语法)
|
|
153
|
+
- `--spooler-url`:spooler 地址
|
|
154
|
+
- `--spooler-token`:可选鉴权 token
|
|
155
|
+
- `--printer-width`:打印宽度(像素)
|
|
156
|
+
- `--feed-lines`:走纸行数
|
|
157
|
+
- `--cut`:追加切纸命令(默认不切)
|
|
158
|
+
- `--divider-only`:只打印一条分割线(用于实机校准)
|
|
159
|
+
- `--divider-chars`:分割线字符数(默认按 `printer_width / 12`)
|
|
160
|
+
|
|
161
|
+
也支持通过环境变量传参:
|
|
162
|
+
|
|
163
|
+
- `RENDER_MODE`
|
|
164
|
+
- `SPOOLER_URL`
|
|
165
|
+
- `SPOOLER_TOKEN`
|
|
166
|
+
- `PRINTER_WIDTH`
|
|
167
|
+
- `FEED_LINES`
|
|
168
|
+
- `SENDER_NAME`
|
|
169
|
+
- `SENDER_ID`
|
|
170
|
+
|
|
171
|
+
该脚本仅用于手动联调与实机验证,不参与自动化测试流程。
|
|
172
|
+
|
|
141
173
|
## 注意事项
|
|
142
174
|
|
|
143
175
|
- 如果你需要打印中文,最好配置 `RECEIPT_FONT_PATH` 指向支持中文的字体文件。
|
|
@@ -29,22 +29,22 @@ nonebot.load_plugin("nonebot_plugin_receipts")
|
|
|
29
29
|
|
|
30
30
|
插件通过 NoneBot 全局配置读取以下环境变量:
|
|
31
31
|
|
|
32
|
-
| 变量
|
|
33
|
-
|
|
|
34
|
-
| `RECEIPTS_SPOOLER_URL`
|
|
35
|
-
| `RECEIPTS_SPOOLER_TOKEN`
|
|
36
|
-
| `RECEIPTS_SPOOLER_TIMEOUT`
|
|
37
|
-
| `RECEIPT_IMAGE_FETCH_TIMEOUT`
|
|
38
|
-
| `RECEIPT_RENDER_MODE`
|
|
39
|
-
| `RECEIPT_PRINTER_WIDTH`
|
|
40
|
-
| `RECEIPT_TEMPLATE_PATH`
|
|
41
|
-
| `RECEIPT_FONT_PATH`
|
|
42
|
-
| `RECEIPT_FONT_SIZE`
|
|
43
|
-
| `RECEIPT_LINE_SPACING`
|
|
44
|
-
| `RECEIPT_SECTION_GAP`
|
|
45
|
-
| `RECEIPT_SESSION_TIMEOUT_SECONDS` | `120`
|
|
46
|
-
| `RECEIPT_FEED_LINES`
|
|
47
|
-
| `RECEIPT_ENABLE_CUT`
|
|
32
|
+
| 变量 | 默认值 | 说明 |
|
|
33
|
+
| --------------------------------- | ----------------------- | ------------------------------------- |
|
|
34
|
+
| `RECEIPTS_SPOOLER_URL` | `http://127.0.0.1:8000` | `receipts-spooler` 服务地址 |
|
|
35
|
+
| `RECEIPTS_SPOOLER_TOKEN` | 空 | 对应 `X-Spooler-Token` |
|
|
36
|
+
| `RECEIPTS_SPOOLER_TIMEOUT` | `5.0` | 调用 spooler 的 HTTP 超时 |
|
|
37
|
+
| `RECEIPT_IMAGE_FETCH_TIMEOUT` | `5.0` | 下载图片消息内容时的 HTTP 超时 |
|
|
38
|
+
| `RECEIPT_RENDER_MODE` | `raster` | 渲染模式,可选 `raster`/`hybrid` |
|
|
39
|
+
| `RECEIPT_PRINTER_WIDTH` | `576` | 小票像素宽度,最大 `576` |
|
|
40
|
+
| `RECEIPT_TEMPLATE_PATH` | 空 | 可选,指向渲染模板 JSON 文件 |
|
|
41
|
+
| `RECEIPT_FONT_PATH` | 空 | 可选,建议配置支持中文的 TTF/OTF 字体 |
|
|
42
|
+
| `RECEIPT_FONT_SIZE` | `24` | 文本字号 |
|
|
43
|
+
| `RECEIPT_LINE_SPACING` | `6` | 文本行距 |
|
|
44
|
+
| `RECEIPT_SECTION_GAP` | `6` | 文本和图片之间的垂直间距 |
|
|
45
|
+
| `RECEIPT_SESSION_TIMEOUT_SECONDS` | `120` | 等待用户补发打印内容的超时时间(秒) |
|
|
46
|
+
| `RECEIPT_FEED_LINES` | `4` | 打印后走纸行数 |
|
|
47
|
+
| `RECEIPT_ENABLE_CUT` | `true` | 是否附加切纸命令 |
|
|
48
48
|
|
|
49
49
|
### 渲染模式
|
|
50
50
|
|
|
@@ -121,6 +121,38 @@ nonebot.load_plugin("nonebot_plugin_receipts")
|
|
|
121
121
|
在 `raster` 模式下会使用更大的字号和额外留白;在 `hybrid` 模式下会尽量映射为 ESC/POS 的加粗和放大文本。
|
|
122
122
|
如果需要打印以 `#` 开头的普通文本,可以写成 `\# 普通文本`。
|
|
123
123
|
|
|
124
|
+
## 手动实机测试(不启动 NoneBot)
|
|
125
|
+
|
|
126
|
+
可直接运行脚本把测试内容渲染为 ESC/POS 并提交到 `receipts-spooler`:
|
|
127
|
+
|
|
128
|
+
```powershell
|
|
129
|
+
python scripts/manual_print_test.py --mode hybrid --spooler-url http://127.0.0.1:8000
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
常用参数:
|
|
133
|
+
|
|
134
|
+
- `--mode hybrid|raster`:渲染模式,默认 `hybrid`
|
|
135
|
+
- `--text`:测试文本(支持 `#` 标题语法)
|
|
136
|
+
- `--spooler-url`:spooler 地址
|
|
137
|
+
- `--spooler-token`:可选鉴权 token
|
|
138
|
+
- `--printer-width`:打印宽度(像素)
|
|
139
|
+
- `--feed-lines`:走纸行数
|
|
140
|
+
- `--cut`:追加切纸命令(默认不切)
|
|
141
|
+
- `--divider-only`:只打印一条分割线(用于实机校准)
|
|
142
|
+
- `--divider-chars`:分割线字符数(默认按 `printer_width / 12`)
|
|
143
|
+
|
|
144
|
+
也支持通过环境变量传参:
|
|
145
|
+
|
|
146
|
+
- `RENDER_MODE`
|
|
147
|
+
- `SPOOLER_URL`
|
|
148
|
+
- `SPOOLER_TOKEN`
|
|
149
|
+
- `PRINTER_WIDTH`
|
|
150
|
+
- `FEED_LINES`
|
|
151
|
+
- `SENDER_NAME`
|
|
152
|
+
- `SENDER_ID`
|
|
153
|
+
|
|
154
|
+
该脚本仅用于手动联调与实机验证,不参与自动化测试流程。
|
|
155
|
+
|
|
124
156
|
## 注意事项
|
|
125
157
|
|
|
126
158
|
- 如果你需要打印中文,最好配置 `RECEIPT_FONT_PATH` 指向支持中文的字体文件。
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from time import monotonic
|
|
4
|
-
from typing import TYPE_CHECKING
|
|
5
4
|
|
|
6
5
|
from nonebot import get_driver, get_plugin_config
|
|
6
|
+
from nonebot.adapters.onebot.v11 import Message, MessageEvent # noqa: TC002
|
|
7
|
+
from nonebot.matcher import Matcher # noqa: TC002
|
|
7
8
|
from nonebot.params import Arg, CommandArg
|
|
8
9
|
|
|
9
10
|
from .config import Config
|
|
@@ -11,11 +12,6 @@ from .renderer import ReceiptRenderError, render_receipt
|
|
|
11
12
|
from .spooler import SpoolerClient, SpoolerError
|
|
12
13
|
from .template import ReceiptTemplateContext
|
|
13
14
|
|
|
14
|
-
if TYPE_CHECKING:
|
|
15
|
-
from nonebot.adapters.onebot.v11 import Message, MessageEvent
|
|
16
|
-
from nonebot.matcher import Matcher
|
|
17
|
-
|
|
18
|
-
|
|
19
15
|
RECEIPT_TIMEOUT_DEADLINE_KEY = "receipt_timeout_deadline"
|
|
20
16
|
|
|
21
17
|
|
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import TYPE_CHECKING
|
|
4
4
|
|
|
5
|
-
from PIL import Image,
|
|
5
|
+
from PIL import Image, ImageOps
|
|
6
6
|
|
|
7
7
|
from .raster_backend import build_image_section
|
|
8
8
|
from .render_types import (
|
|
@@ -11,7 +11,6 @@ from .render_types import (
|
|
|
11
11
|
ReceiptBlock,
|
|
12
12
|
ReceiptRenderError,
|
|
13
13
|
)
|
|
14
|
-
from .render_utils import load_font, measure_text
|
|
15
14
|
from .text_markup import (
|
|
16
15
|
encode_native_text,
|
|
17
16
|
encode_styled_native_text,
|
|
@@ -25,6 +24,11 @@ if TYPE_CHECKING:
|
|
|
25
24
|
from .template import ReceiptTemplate, ReceiptTemplateContext
|
|
26
25
|
|
|
27
26
|
|
|
27
|
+
NATIVE_CHAR_WIDTH_PX = 12
|
|
28
|
+
DIVIDER_BEFORE = "before"
|
|
29
|
+
DIVIDER_AFTER = "after"
|
|
30
|
+
|
|
31
|
+
|
|
28
32
|
def render_hybrid_escpos(
|
|
29
33
|
blocks: Sequence[ReceiptBlock],
|
|
30
34
|
config: Config,
|
|
@@ -37,21 +41,39 @@ def render_hybrid_escpos(
|
|
|
37
41
|
raise ReceiptRenderError(ReceiptRenderError.INVALID_LAYOUT)
|
|
38
42
|
|
|
39
43
|
chunks: list[bytes] = [b"\x1b@"]
|
|
44
|
+
divider_width_chars = max(1, layout.width // NATIVE_CHAR_WIDTH_PX)
|
|
40
45
|
|
|
41
46
|
header_text = template.render_header_text(template_context)
|
|
42
47
|
append_hybrid_text_section(
|
|
43
48
|
chunks,
|
|
44
49
|
header_text,
|
|
45
50
|
layout.width_chars,
|
|
46
|
-
|
|
51
|
+
divider_width_chars,
|
|
52
|
+
divider=DIVIDER_AFTER,
|
|
47
53
|
)
|
|
48
54
|
|
|
49
|
-
has_body_content =
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
has_body_content = False
|
|
56
|
+
for block in blocks:
|
|
57
|
+
if block.kind == "text" and block.text:
|
|
58
|
+
styled_lines = parse_styled_lines(block.text)
|
|
59
|
+
if not styled_lines:
|
|
60
|
+
continue
|
|
61
|
+
chunks.append(encode_styled_native_text(styled_lines, layout.width_chars))
|
|
62
|
+
has_body_content = True
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
if block.kind != "image" or block.image is None:
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
section = build_image_section(
|
|
69
|
+
block.image,
|
|
70
|
+
layout.width,
|
|
71
|
+
layout.content_width,
|
|
72
|
+
config.receipt_section_gap,
|
|
73
|
+
)
|
|
74
|
+
chunks.append(image_to_escpos(section, config, initialize=False))
|
|
75
|
+
has_body_content = True
|
|
76
|
+
|
|
55
77
|
if not has_body_content:
|
|
56
78
|
raise ReceiptRenderError(ReceiptRenderError.EMPTY_CONTENT)
|
|
57
79
|
|
|
@@ -60,7 +82,8 @@ def render_hybrid_escpos(
|
|
|
60
82
|
chunks,
|
|
61
83
|
footer_text,
|
|
62
84
|
layout.width_chars,
|
|
63
|
-
|
|
85
|
+
divider_width_chars,
|
|
86
|
+
divider=DIVIDER_BEFORE,
|
|
64
87
|
)
|
|
65
88
|
|
|
66
89
|
if config.receipt_feed_lines:
|
|
@@ -76,11 +99,7 @@ def build_hybrid_layout(config: Config, template: ReceiptTemplate) -> HybridLayo
|
|
|
76
99
|
margin = template.margin
|
|
77
100
|
content_width = width - margin * 2
|
|
78
101
|
|
|
79
|
-
|
|
80
|
-
probe = Image.new("L", (width, 10), color=255)
|
|
81
|
-
draw = ImageDraw.Draw(probe)
|
|
82
|
-
avg_char_width = max(1, measure_text(draw, "AA", font) // 2)
|
|
83
|
-
width_chars = max(1, content_width // avg_char_width)
|
|
102
|
+
width_chars = max(1, content_width // NATIVE_CHAR_WIDTH_PX)
|
|
84
103
|
return HybridLayout(
|
|
85
104
|
width=width,
|
|
86
105
|
content_width=content_width,
|
|
@@ -88,18 +107,12 @@ def build_hybrid_layout(config: Config, template: ReceiptTemplate) -> HybridLayo
|
|
|
88
107
|
)
|
|
89
108
|
|
|
90
109
|
|
|
91
|
-
def build_native_divider(width_chars: int) -> list[str]:
|
|
92
|
-
"""Build a text divider sized to the current printer width."""
|
|
93
|
-
return ["-" * max(1, width_chars)]
|
|
94
|
-
|
|
95
|
-
|
|
96
110
|
def append_hybrid_text_section(
|
|
97
111
|
chunks: list[bytes],
|
|
98
112
|
text: str,
|
|
99
113
|
width_chars: int,
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
add_divider_after: bool = False,
|
|
114
|
+
divider_width_chars: int,
|
|
115
|
+
divider: str = "",
|
|
103
116
|
) -> None:
|
|
104
117
|
"""Append wrapped native text lines and an optional divider."""
|
|
105
118
|
if not text:
|
|
@@ -109,57 +122,11 @@ def append_hybrid_text_section(
|
|
|
109
122
|
if not styled_lines:
|
|
110
123
|
return
|
|
111
124
|
|
|
112
|
-
if
|
|
113
|
-
chunks.append(encode_native_text(
|
|
125
|
+
if divider == DIVIDER_BEFORE:
|
|
126
|
+
chunks.append(encode_native_text(["-" * divider_width_chars]))
|
|
114
127
|
chunks.append(encode_styled_native_text(styled_lines, width_chars))
|
|
115
|
-
if
|
|
116
|
-
chunks.append(encode_native_text(
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def append_hybrid_body_sections(
|
|
120
|
-
chunks: list[bytes],
|
|
121
|
-
blocks: Sequence[ReceiptBlock],
|
|
122
|
-
config: Config,
|
|
123
|
-
layout: HybridLayout,
|
|
124
|
-
) -> bool:
|
|
125
|
-
"""Append mixed native-text and raster-image body sections."""
|
|
126
|
-
has_body_content = False
|
|
127
|
-
for block in blocks:
|
|
128
|
-
appended = append_hybrid_body_section(
|
|
129
|
-
chunks,
|
|
130
|
-
block,
|
|
131
|
-
config,
|
|
132
|
-
layout,
|
|
133
|
-
)
|
|
134
|
-
has_body_content = has_body_content or appended
|
|
135
|
-
return has_body_content
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def append_hybrid_body_section(
|
|
139
|
-
chunks: list[bytes],
|
|
140
|
-
block: ReceiptBlock,
|
|
141
|
-
config: Config,
|
|
142
|
-
layout: HybridLayout,
|
|
143
|
-
) -> bool:
|
|
144
|
-
"""Append one body block to hybrid ESC/POS output."""
|
|
145
|
-
if block.kind == "text" and block.text:
|
|
146
|
-
styled_lines = parse_styled_lines(block.text)
|
|
147
|
-
if not styled_lines:
|
|
148
|
-
return False
|
|
149
|
-
chunks.append(encode_styled_native_text(styled_lines, layout.width_chars))
|
|
150
|
-
return True
|
|
151
|
-
|
|
152
|
-
if block.kind == "image" and block.image is not None:
|
|
153
|
-
section = build_image_section(
|
|
154
|
-
block.image,
|
|
155
|
-
layout.width,
|
|
156
|
-
layout.content_width,
|
|
157
|
-
config.receipt_section_gap,
|
|
158
|
-
)
|
|
159
|
-
chunks.append(image_to_escpos(section, config, initialize=False))
|
|
160
|
-
return True
|
|
161
|
-
|
|
162
|
-
return False
|
|
128
|
+
if divider == DIVIDER_AFTER:
|
|
129
|
+
chunks.append(encode_native_text(["-" * divider_width_chars]))
|
|
163
130
|
|
|
164
131
|
|
|
165
132
|
def image_to_escpos(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nonebot_plugin_receipts
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: NoneBot2 plugin for sending text and image messages to receipts-spooler
|
|
5
5
|
Project-URL: homepage, https://github.com/Effect-Wei/nonebot_plugin_receipts
|
|
6
6
|
Project-URL: repository, https://github.com/Effect-Wei/nonebot_plugin_receipts
|
|
@@ -46,22 +46,22 @@ nonebot.load_plugin("nonebot_plugin_receipts")
|
|
|
46
46
|
|
|
47
47
|
插件通过 NoneBot 全局配置读取以下环境变量:
|
|
48
48
|
|
|
49
|
-
| 变量
|
|
50
|
-
|
|
|
51
|
-
| `RECEIPTS_SPOOLER_URL`
|
|
52
|
-
| `RECEIPTS_SPOOLER_TOKEN`
|
|
53
|
-
| `RECEIPTS_SPOOLER_TIMEOUT`
|
|
54
|
-
| `RECEIPT_IMAGE_FETCH_TIMEOUT`
|
|
55
|
-
| `RECEIPT_RENDER_MODE`
|
|
56
|
-
| `RECEIPT_PRINTER_WIDTH`
|
|
57
|
-
| `RECEIPT_TEMPLATE_PATH`
|
|
58
|
-
| `RECEIPT_FONT_PATH`
|
|
59
|
-
| `RECEIPT_FONT_SIZE`
|
|
60
|
-
| `RECEIPT_LINE_SPACING`
|
|
61
|
-
| `RECEIPT_SECTION_GAP`
|
|
62
|
-
| `RECEIPT_SESSION_TIMEOUT_SECONDS` | `120`
|
|
63
|
-
| `RECEIPT_FEED_LINES`
|
|
64
|
-
| `RECEIPT_ENABLE_CUT`
|
|
49
|
+
| 变量 | 默认值 | 说明 |
|
|
50
|
+
| --------------------------------- | ----------------------- | ------------------------------------- |
|
|
51
|
+
| `RECEIPTS_SPOOLER_URL` | `http://127.0.0.1:8000` | `receipts-spooler` 服务地址 |
|
|
52
|
+
| `RECEIPTS_SPOOLER_TOKEN` | 空 | 对应 `X-Spooler-Token` |
|
|
53
|
+
| `RECEIPTS_SPOOLER_TIMEOUT` | `5.0` | 调用 spooler 的 HTTP 超时 |
|
|
54
|
+
| `RECEIPT_IMAGE_FETCH_TIMEOUT` | `5.0` | 下载图片消息内容时的 HTTP 超时 |
|
|
55
|
+
| `RECEIPT_RENDER_MODE` | `raster` | 渲染模式,可选 `raster`/`hybrid` |
|
|
56
|
+
| `RECEIPT_PRINTER_WIDTH` | `576` | 小票像素宽度,最大 `576` |
|
|
57
|
+
| `RECEIPT_TEMPLATE_PATH` | 空 | 可选,指向渲染模板 JSON 文件 |
|
|
58
|
+
| `RECEIPT_FONT_PATH` | 空 | 可选,建议配置支持中文的 TTF/OTF 字体 |
|
|
59
|
+
| `RECEIPT_FONT_SIZE` | `24` | 文本字号 |
|
|
60
|
+
| `RECEIPT_LINE_SPACING` | `6` | 文本行距 |
|
|
61
|
+
| `RECEIPT_SECTION_GAP` | `6` | 文本和图片之间的垂直间距 |
|
|
62
|
+
| `RECEIPT_SESSION_TIMEOUT_SECONDS` | `120` | 等待用户补发打印内容的超时时间(秒) |
|
|
63
|
+
| `RECEIPT_FEED_LINES` | `4` | 打印后走纸行数 |
|
|
64
|
+
| `RECEIPT_ENABLE_CUT` | `true` | 是否附加切纸命令 |
|
|
65
65
|
|
|
66
66
|
### 渲染模式
|
|
67
67
|
|
|
@@ -138,6 +138,38 @@ nonebot.load_plugin("nonebot_plugin_receipts")
|
|
|
138
138
|
在 `raster` 模式下会使用更大的字号和额外留白;在 `hybrid` 模式下会尽量映射为 ESC/POS 的加粗和放大文本。
|
|
139
139
|
如果需要打印以 `#` 开头的普通文本,可以写成 `\# 普通文本`。
|
|
140
140
|
|
|
141
|
+
## 手动实机测试(不启动 NoneBot)
|
|
142
|
+
|
|
143
|
+
可直接运行脚本把测试内容渲染为 ESC/POS 并提交到 `receipts-spooler`:
|
|
144
|
+
|
|
145
|
+
```powershell
|
|
146
|
+
python scripts/manual_print_test.py --mode hybrid --spooler-url http://127.0.0.1:8000
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
常用参数:
|
|
150
|
+
|
|
151
|
+
- `--mode hybrid|raster`:渲染模式,默认 `hybrid`
|
|
152
|
+
- `--text`:测试文本(支持 `#` 标题语法)
|
|
153
|
+
- `--spooler-url`:spooler 地址
|
|
154
|
+
- `--spooler-token`:可选鉴权 token
|
|
155
|
+
- `--printer-width`:打印宽度(像素)
|
|
156
|
+
- `--feed-lines`:走纸行数
|
|
157
|
+
- `--cut`:追加切纸命令(默认不切)
|
|
158
|
+
- `--divider-only`:只打印一条分割线(用于实机校准)
|
|
159
|
+
- `--divider-chars`:分割线字符数(默认按 `printer_width / 12`)
|
|
160
|
+
|
|
161
|
+
也支持通过环境变量传参:
|
|
162
|
+
|
|
163
|
+
- `RENDER_MODE`
|
|
164
|
+
- `SPOOLER_URL`
|
|
165
|
+
- `SPOOLER_TOKEN`
|
|
166
|
+
- `PRINTER_WIDTH`
|
|
167
|
+
- `FEED_LINES`
|
|
168
|
+
- `SENDER_NAME`
|
|
169
|
+
- `SENDER_ID`
|
|
170
|
+
|
|
171
|
+
该脚本仅用于手动联调与实机验证,不参与自动化测试流程。
|
|
172
|
+
|
|
141
173
|
## 注意事项
|
|
142
174
|
|
|
143
175
|
- 如果你需要打印中文,最好配置 `RECEIPT_FONT_PATH` 指向支持中文的字体文件。
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "nonebot_plugin_receipts"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "NoneBot2 plugin for sending text and image messages to receipts-spooler"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12, <4.0"
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING, cast
|
|
6
|
+
from unittest.mock import AsyncMock, patch
|
|
7
|
+
|
|
8
|
+
from nonebot_plugin_receipts.config import Config
|
|
9
|
+
from nonebot_plugin_receipts.escpos_backend import (
|
|
10
|
+
NATIVE_CHAR_WIDTH_PX,
|
|
11
|
+
render_hybrid_escpos,
|
|
12
|
+
)
|
|
13
|
+
from nonebot_plugin_receipts.render_types import ReceiptBlock
|
|
14
|
+
from nonebot_plugin_receipts.renderer import render_receipt
|
|
15
|
+
from nonebot_plugin_receipts.template import ReceiptTemplate, ReceiptTemplateContext
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
EXPECTED_DIVIDER_CHARS = 48
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RendererIntegrationTestCase(unittest.IsolatedAsyncioTestCase):
|
|
25
|
+
def setUp(self) -> None:
|
|
26
|
+
self.template_path = str(
|
|
27
|
+
Path(__file__).resolve().parents[1] / "receipt_template.example.json"
|
|
28
|
+
)
|
|
29
|
+
self.context = ReceiptTemplateContext(sender_name="Tester", sender_id="10001")
|
|
30
|
+
|
|
31
|
+
async def test_render_receipt_returns_bytes_in_raster_mode(self) -> None:
|
|
32
|
+
config = Config(
|
|
33
|
+
receipt_render_mode="raster",
|
|
34
|
+
receipt_template_path=self.template_path,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
with patch(
|
|
38
|
+
"nonebot_plugin_receipts.renderer.extract_blocks",
|
|
39
|
+
new=AsyncMock(
|
|
40
|
+
return_value=[ReceiptBlock(kind="text", text="# Title\nBody")]
|
|
41
|
+
),
|
|
42
|
+
):
|
|
43
|
+
rendered = await render_receipt(cast("Any", []), config, self.context)
|
|
44
|
+
|
|
45
|
+
self.assertIsInstance(rendered, bytes)
|
|
46
|
+
self.assertGreater(len(rendered), 0)
|
|
47
|
+
|
|
48
|
+
async def test_render_receipt_returns_bytes_in_hybrid_mode(self) -> None:
|
|
49
|
+
config = Config(
|
|
50
|
+
receipt_render_mode="hybrid",
|
|
51
|
+
receipt_template_path=self.template_path,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
with patch(
|
|
55
|
+
"nonebot_plugin_receipts.renderer.extract_blocks",
|
|
56
|
+
new=AsyncMock(
|
|
57
|
+
return_value=[ReceiptBlock(kind="text", text="## Title\nBody")]
|
|
58
|
+
),
|
|
59
|
+
):
|
|
60
|
+
rendered = await render_receipt(cast("Any", []), config, self.context)
|
|
61
|
+
|
|
62
|
+
self.assertIsInstance(rendered, bytes)
|
|
63
|
+
self.assertGreater(len(rendered), 0)
|
|
64
|
+
|
|
65
|
+
async def test_render_receipt_hybrid_uses_48_char_divider(self) -> None:
|
|
66
|
+
config = Config(
|
|
67
|
+
receipt_render_mode="hybrid",
|
|
68
|
+
receipt_template_path=self.template_path,
|
|
69
|
+
receipt_enable_cut=False,
|
|
70
|
+
receipt_feed_lines=0,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
with patch(
|
|
74
|
+
"nonebot_plugin_receipts.renderer.extract_blocks",
|
|
75
|
+
new=AsyncMock(
|
|
76
|
+
return_value=[ReceiptBlock(kind="text", text="# Title\nBody")]
|
|
77
|
+
),
|
|
78
|
+
):
|
|
79
|
+
rendered = await render_receipt(cast("Any", []), config, self.context)
|
|
80
|
+
|
|
81
|
+
decoded = rendered.decode("gb18030", errors="ignore")
|
|
82
|
+
divider_lines = [
|
|
83
|
+
line for line in decoded.splitlines() if line and set(line) == {"-"}
|
|
84
|
+
]
|
|
85
|
+
self.assertEqual(len(divider_lines), 2)
|
|
86
|
+
self.assertTrue(
|
|
87
|
+
all(len(line) == EXPECTED_DIVIDER_CHARS for line in divider_lines)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def test_hybrid_fixed_native_char_width(self) -> None:
|
|
91
|
+
self.assertEqual(NATIVE_CHAR_WIDTH_PX, 12)
|
|
92
|
+
|
|
93
|
+
def test_hybrid_divider_is_not_wrapped(self) -> None:
|
|
94
|
+
config = Config(
|
|
95
|
+
receipt_render_mode="hybrid",
|
|
96
|
+
receipt_template_path=self.template_path,
|
|
97
|
+
receipt_enable_cut=False,
|
|
98
|
+
receipt_feed_lines=0,
|
|
99
|
+
)
|
|
100
|
+
template = ReceiptTemplate(
|
|
101
|
+
margin=16,
|
|
102
|
+
header_enabled=True,
|
|
103
|
+
header_text="Header",
|
|
104
|
+
footer_enabled=True,
|
|
105
|
+
footer_text="Footer",
|
|
106
|
+
)
|
|
107
|
+
rendered = render_hybrid_escpos(
|
|
108
|
+
[ReceiptBlock(kind="text", text="Body")],
|
|
109
|
+
config,
|
|
110
|
+
template,
|
|
111
|
+
self.context,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
decoded = rendered.decode("gb18030", errors="ignore")
|
|
115
|
+
divider_lines = [
|
|
116
|
+
line for line in decoded.splitlines() if line and set(line) == {"-"}
|
|
117
|
+
]
|
|
118
|
+
self.assertEqual(len(divider_lines), 2)
|
|
119
|
+
self.assertTrue(
|
|
120
|
+
all(len(line) == EXPECTED_DIVIDER_CHARS for line in divider_lines)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
unittest.main()
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import unittest
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from typing import TYPE_CHECKING, cast
|
|
6
|
-
from unittest.mock import AsyncMock, patch
|
|
7
|
-
|
|
8
|
-
from nonebot_plugin_receipts.config import Config
|
|
9
|
-
from nonebot_plugin_receipts.render_types import ReceiptBlock
|
|
10
|
-
from nonebot_plugin_receipts.renderer import render_receipt
|
|
11
|
-
from nonebot_plugin_receipts.template import ReceiptTemplateContext
|
|
12
|
-
|
|
13
|
-
if TYPE_CHECKING:
|
|
14
|
-
from typing import Any
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class RendererIntegrationTestCase(unittest.IsolatedAsyncioTestCase):
|
|
18
|
-
def setUp(self) -> None:
|
|
19
|
-
self.template_path = str(
|
|
20
|
-
Path(__file__).resolve().parents[1] / "receipt_template.example.json"
|
|
21
|
-
)
|
|
22
|
-
self.context = ReceiptTemplateContext(sender_name="Tester", sender_id="10001")
|
|
23
|
-
|
|
24
|
-
async def test_render_receipt_returns_bytes_in_raster_mode(self) -> None:
|
|
25
|
-
config = Config(
|
|
26
|
-
receipt_render_mode="raster",
|
|
27
|
-
receipt_template_path=self.template_path,
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
with patch(
|
|
31
|
-
"nonebot_plugin_receipts.renderer.extract_blocks",
|
|
32
|
-
new=AsyncMock(
|
|
33
|
-
return_value=[ReceiptBlock(kind="text", text="# Title\nBody")]
|
|
34
|
-
),
|
|
35
|
-
):
|
|
36
|
-
rendered = await render_receipt(cast("Any", []), config, self.context)
|
|
37
|
-
|
|
38
|
-
self.assertIsInstance(rendered, bytes)
|
|
39
|
-
self.assertGreater(len(rendered), 0)
|
|
40
|
-
|
|
41
|
-
async def test_render_receipt_returns_bytes_in_hybrid_mode(self) -> None:
|
|
42
|
-
config = Config(
|
|
43
|
-
receipt_render_mode="hybrid",
|
|
44
|
-
receipt_template_path=self.template_path,
|
|
45
|
-
)
|
|
46
|
-
|
|
47
|
-
with patch(
|
|
48
|
-
"nonebot_plugin_receipts.renderer.extract_blocks",
|
|
49
|
-
new=AsyncMock(
|
|
50
|
-
return_value=[ReceiptBlock(kind="text", text="## Title\nBody")]
|
|
51
|
-
),
|
|
52
|
-
):
|
|
53
|
-
rendered = await render_receipt(cast("Any", []), config, self.context)
|
|
54
|
-
|
|
55
|
-
self.assertIsInstance(rendered, bytes)
|
|
56
|
-
self.assertGreater(len(rendered), 0)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if __name__ == "__main__":
|
|
60
|
-
unittest.main()
|
{nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/__init__.py
RENAMED
|
File without changes
|
{nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/config.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/renderer.py
RENAMED
|
File without changes
|
{nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/spooler.py
RENAMED
|
File without changes
|
{nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/template.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/tests/test_command_handlers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|