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.
Files changed (26) hide show
  1. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/PKG-INFO +49 -17
  2. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/README.md +48 -16
  3. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/command_handlers.py +2 -6
  4. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/escpos_backend.py +40 -73
  5. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts.egg-info/PKG-INFO +49 -17
  6. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/pyproject.toml +1 -1
  7. nonebot_plugin_receipts-0.2.2/tests/test_renderer.py +125 -0
  8. nonebot_plugin_receipts-0.2.0/tests/test_renderer.py +0 -60
  9. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/__init__.py +0 -0
  10. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/config.py +0 -0
  11. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/raster_backend.py +0 -0
  12. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/render_blocks.py +0 -0
  13. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/render_types.py +0 -0
  14. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/render_utils.py +0 -0
  15. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/renderer.py +0 -0
  16. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/spooler.py +0 -0
  17. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/template.py +0 -0
  18. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts/text_markup.py +0 -0
  19. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts.egg-info/SOURCES.txt +0 -0
  20. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts.egg-info/dependency_links.txt +0 -0
  21. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts.egg-info/requires.txt +0 -0
  22. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/nonebot_plugin_receipts.egg-info/top_level.txt +0 -0
  23. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/setup.cfg +0 -0
  24. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/tests/test_command_handlers.py +0 -0
  25. {nonebot_plugin_receipts-0.2.0 → nonebot_plugin_receipts-0.2.2}/tests/test_render_blocks.py +0 -0
  26. {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.0
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` | `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` | 是否附加切纸命令 |
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` | `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` | 是否附加切纸命令 |
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, ImageDraw, ImageOps
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
- add_divider_after=True,
51
+ divider_width_chars,
52
+ divider=DIVIDER_AFTER,
47
53
  )
48
54
 
49
- has_body_content = append_hybrid_body_sections(
50
- chunks,
51
- blocks,
52
- config,
53
- layout,
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
- add_divider_before=True,
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
- font = load_font(config)
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
- add_divider_before: bool = False,
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 add_divider_before:
113
- chunks.append(encode_native_text(build_native_divider(width_chars)))
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 add_divider_after:
116
- chunks.append(encode_native_text(build_native_divider(width_chars)))
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.0
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` | `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` | 是否附加切纸命令 |
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.0"
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()