diff --git a/README.md b/README.md
index 1c722cd..1100cb3 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@ dash 顶部已加入低压电池电压 `lvbat`(两位小数),来源是 SDC
- [AMI.dbc](AMI.dbc):当前 AMI CAN 接口,10 条报文、33 个信号,含标准/扩展帧。
- [DBC 对应说明](docs/AMI_CAN_DBC说明.md):信号布局、枚举、字节序、样例与复现检查。
+- [CAN 报文说明 PDF](output/pdf/AMI_CAN报文说明.pdf):当前 v2.0 协议的中文打印版,包含全部 10 条报文。
- [联调报告](docs/SDC_BMS联调报告.md):真实 CAN1 / MCU / COM4 到 HMI 调试器的证据。
- [已联调固件 BIN](output/firmware/AMI_Controller.bin):STM32G0B1CBT6,配套源码在 firmware。
- [AMI.HMI](AMI.HMI):最新版屏幕工程,打开后编译并下载到对应型号屏幕。
@@ -54,3 +55,15 @@ dash.lvbat.val=1260
灰色数值表示未知或失效。现有资料缺少电机/逆变器温度协议、Hz 到 km/h 标定参数、SDC PD0..PD14 到具名安全信号的映射,对应显示保持无效/未知。VCU 任务裁决、执行反馈和 EBS 安全输出仍需车辆控制协议,画面 EBS 文案不能作为安全状态证明。
许可证沿用仓库中的 [AGPL-3.0](LICENSE)。
+
+## 导出 CAN 报文 PDF
+
+PDF 与 `docs/AMI_CAN_DBC说明.md` 使用同一份内容,按 A4 横向排版并嵌入中文字体。在仓库根目录运行:
+
+```powershell
+python -m pip install -r scripts/requirements-pdf.txt
+python scripts/export_dbc_docs.py
+python scripts/export_can_pdf.py
+```
+
+默认使用 Windows 黑体 `C:/Windows/Fonts/simhei.ttf`;其他环境可通过 `--font` 指定支持中文的 TrueType 字体。输出为 `output/pdf/AMI_CAN报文说明.pdf`。修改 DBC 后,需重新导出 Markdown 和 PDF。
diff --git a/output/pdf/AMI_CAN报文说明.pdf b/output/pdf/AMI_CAN报文说明.pdf
new file mode 100644
index 0000000..9ca93e5
Binary files /dev/null and b/output/pdf/AMI_CAN报文说明.pdf differ
diff --git a/scripts/export_can_pdf.py b/scripts/export_can_pdf.py
new file mode 100644
index 0000000..3cd91f0
--- /dev/null
+++ b/scripts/export_can_pdf.py
@@ -0,0 +1,147 @@
+"""Render the verified Chinese CAN Markdown document as a printable PDF."""
+import argparse
+import html
+import re
+from pathlib import Path
+
+from reportlab.lib import colors
+from reportlab.lib.enums import TA_LEFT
+from reportlab.lib.pagesizes import A4, landscape
+from reportlab.lib.styles import ParagraphStyle
+from reportlab.pdfbase import pdfmetrics
+from reportlab.pdfbase.ttfonts import TTFont
+from reportlab.platypus import (
+ KeepTogether, PageBreak, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle,
+)
+
+ROOT = Path(__file__).resolve().parents[1]
+SOURCE = ROOT / 'docs/AMI_CAN_DBC说明.md'
+OUTPUT = ROOT / 'output/pdf/AMI_CAN报文说明.pdf'
+REMOTE = 'https://git.acetaffy.ltd/acetaffy/AMI/src/branch/main/'
+
+
+def inline(text):
+ """Escape Markdown and retain useful links in the printed document."""
+ text = html.escape(text)
+ text = re.sub(r'`([^`]+)`', r'\1', text)
+
+ def link(match):
+ label, target = match.groups()
+ if not target.startswith(('http://', 'https://')):
+ target = REMOTE + ('AMI.dbc' if target == '../AMI.dbc' else 'docs/' + target)
+ return f'{label}'
+
+ return re.sub(r'\[([^\]]+)\]\(([^)]+)\)', link, text)
+
+
+def export(font_path):
+ pdfmetrics.registerFont(TTFont('AMIChinese', str(font_path), subfontIndex=0))
+ page_width, page_height = landscape(A4)
+ width = page_width - 80
+ body = ParagraphStyle('Body', fontName='AMIChinese', fontSize=9.3,
+ leading=15, spaceAfter=8, wordWrap='CJK', alignment=TA_LEFT)
+ heading = ParagraphStyle('Heading', parent=body, fontSize=16, leading=23,
+ textColor=colors.HexColor('#12485E'), spaceBefore=3,
+ spaceAfter=12, keepWithNext=True)
+ subheading = ParagraphStyle('Subheading', parent=body, fontSize=11,
+ leading=17, textColor=colors.HexColor('#12485E'),
+ spaceBefore=8, spaceAfter=6, keepWithNext=True)
+ title = ParagraphStyle('Title', parent=heading, fontSize=25, leading=32)
+ cell = ParagraphStyle('Cell', parent=body, fontSize=8.5, leading=12, spaceAfter=0)
+ code = ParagraphStyle('Code', parent=body, fontSize=8, leading=13,
+ backColor=colors.HexColor('#F2F5F7'), borderPadding=8)
+ lines = SOURCE.read_text(encoding='utf-8').splitlines()
+ story = []
+ i = 0
+ while i < len(lines):
+ line = lines[i]
+ if not line.strip():
+ i += 1
+ continue
+ if line.startswith('# '):
+ story.append(Paragraph('AMI CAN 报文说明', title))
+ elif line.startswith('## '):
+ section = line[3:]
+ if section != '使用范围':
+ story.append(PageBreak())
+ story.append(Paragraph(inline(section), heading))
+ elif line.startswith('### '):
+ story.append(Paragraph(inline(line[4:]), subheading))
+ elif line.startswith('|'):
+ rows = []
+ while i < len(lines) and lines[i].startswith('|'):
+ row = [x.strip() for x in lines[i].strip().strip('|').split('|')]
+ if not all(re.fullmatch(r':?-+:?', x) for x in row):
+ rows.append([Paragraph(inline(x), cell) for x in row])
+ i += 1
+ count = len(rows[0])
+ proportions = {
+ 2: [0.15, 0.85],
+ 3: [0.17, 0.40, 0.43],
+ 6: [0.11, 0.055, 0.04, 0.28, 0.365, 0.15],
+ 8: [0.32, 0.055, 0.055, 0.10, 0.09, 0.08, 0.06, 0.24],
+ }[count]
+ total = sum(proportions)
+ table = Table(rows, colWidths=[width*x/total for x in proportions],
+ repeatRows=1, hAlign='LEFT')
+ table.setStyle(TableStyle([
+ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#DDECF2')),
+ ('ROWBACKGROUNDS', (0, 1), (-1, -1),
+ [colors.white, colors.HexColor('#F5F8FA')]),
+ ('LINEBELOW', (0, 0), (-1, 0), 0.8, colors.HexColor('#7DABB9')),
+ ('LINEBELOW', (0, 1), (-1, -1), 0.3, colors.HexColor('#D7E0E5')),
+ ('VALIGN', (0, 0), (-1, -1), 'TOP'),
+ ('LEFTPADDING', (0, 0), (-1, -1), 7),
+ ('RIGHTPADDING', (0, 0), (-1, -1), 7),
+ ('TOPPADDING', (0, 0), (-1, -1), 6),
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 6),
+ ]))
+ if i+1 < len(lines) and not lines[i].strip() and lines[i+1].startswith('`'):
+ group = [table, Spacer(1, 10), Paragraph(inline(lines[i+1]), body)]
+ if story and isinstance(story[-1], Paragraph) and story[-1].style is subheading:
+ group.insert(0, story.pop())
+ story.append(KeepTogether(group))
+ i += 2
+ else:
+ story.extend([table, Spacer(1, 10)])
+ continue
+ elif line.startswith('```'):
+ block = []
+ i += 1
+ while i < len(lines) and not lines[i].startswith('```'):
+ block.append(html.escape(lines[i]))
+ i += 1
+ story.append(KeepTogether([Paragraph('
'.join(block), code)]))
+ else:
+ paragraph = [line]
+ while i+1 < len(lines) and lines[i+1].strip() and not lines[i+1].startswith(('#', '|', '```')):
+ i += 1
+ paragraph.append(lines[i])
+ story.append(Paragraph(inline(' '.join(paragraph)), body))
+ i += 1
+
+ def footer(canvas, doc):
+ canvas.saveState()
+ canvas.setStrokeColor(colors.HexColor('#C4D8E1'))
+ canvas.line(40, 31, page_width-40, 31)
+ canvas.setFont('AMIChinese', 8)
+ canvas.setFillColor(colors.HexColor('#506775'))
+ canvas.drawString(40, 19, 'AMI-2.0 | 2026-10-03 | CAN 接口说明')
+ canvas.drawRightString(page_width-40, 19, f'{doc.page}')
+ canvas.restoreState()
+
+ OUTPUT.parent.mkdir(parents=True, exist_ok=True)
+ document = SimpleDocTemplate(str(OUTPUT), pagesize=landscape(A4),
+ leftMargin=40, rightMargin=40,
+ topMargin=36, bottomMargin=44,
+ title='AMI CAN 报文说明', author='AMI / YMR',
+ subject='AMI CAN DBC v2.0: SDC, BMS and task requests')
+ document.build(story, onFirstPage=footer, onLaterPages=footer)
+ print(OUTPUT)
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--font', type=Path, default=Path('C:/Windows/Fonts/simhei.ttf'),
+ help='CJK TrueType font path (default: Windows SimHei)')
+ export(parser.parse_args().font)
diff --git a/scripts/requirements-pdf.txt b/scripts/requirements-pdf.txt
new file mode 100644
index 0000000..b1df017
--- /dev/null
+++ b/scripts/requirements-pdf.txt
@@ -0,0 +1 @@
+reportlab==5.0.1