Files
OnebotCatalog/onebot-data/tools/manual-pages-to-pdf.py

177 lines
7.0 KiB
Python

# -*- coding: utf-8 -*-
"""
manual-pages-to-pdf.py —— 把各系列手册页 PNG 打包成 PDF (供"下载数据表 PDF"用)
背景: 尺寸图窗口显示的手册页是 PNG (manual\\<系列>\\pXX.png)。客户还需要能
下载 PDF 文档 → 本脚本把每个系列的手册页按序合成一个 PDF, 输出
manual\\<系列>\\<系列>_manual.pdf, 并把 datasheet 附件写进 series\\<系列>.meta.json
(软件里【下载数据表 PDF】按钮直接可用, 网页/桌面两端一致)。
纯标准库实现 (zlib 解/压缩 PNG + 手写 PDF), 无任何第三方依赖。
真实手册替换 pXX.png 后重跑本脚本即可更新 PDF。
用法: python manual-pages-to-pdf.py
"""
import json
import os
import struct
import zlib
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MANUAL_DIR = os.path.join(BASE, 'catalog', 'manual')
SERIES_DIR = os.path.join(BASE, 'catalog', 'series')
# A4 页面 (pt)
PAGE_W, PAGE_H = 595.28, 841.89
def parse_png(path):
"""解析 PNG → (width, height, RGB 字节串)。支持 8bit 灰度/RGB/RGBA, 非隔行。"""
with open(path, 'rb') as f:
data = f.read()
assert data[:8] == b'\x89PNG\r\n\x1a\n', '非 PNG: %s' % path
pos, idat, w, h, bd, ct, interlace = 8, b'', 0, 0, 0, 0, 0
while pos < len(data):
(length,) = struct.unpack('>I', data[pos:pos + 4])
typ = data[pos + 4:pos + 8]
chunk = data[pos + 8:pos + 8 + length]
if typ == b'IHDR':
w, h, bd, ct, _, _, interlace = struct.unpack('>IIBBBBB', chunk)
elif typ == b'IDAT':
idat += chunk
elif typ == b'IEND':
break
pos += 12 + length
assert bd == 8 and interlace == 0, '仅支持 8bit 非隔行 PNG'
channels = {0: 1, 2: 3, 4: 2, 6: 4}.get(ct)
assert channels, '不支持的色彩类型 %d' % ct
raw = zlib.decompress(idat)
bpp = channels
stride = w * bpp
out = bytearray()
prev = bytearray(stride)
for y in range(h):
f = raw[y * (stride + 1)]
line = bytearray(raw[y * (stride + 1) + 1:(y + 1) * (stride + 1)])
if f == 1: # Sub
for i in range(bpp, stride):
line[i] = (line[i] + line[i - bpp]) & 0xFF
elif f == 2: # Up
for i in range(stride):
line[i] = (line[i] + prev[i]) & 0xFF
elif f == 3: # Average
for i in range(stride):
a = line[i - bpp] if i >= bpp else 0
line[i] = (line[i] + ((a + prev[i]) >> 1)) & 0xFF
elif f == 4: # Paeth
for i in range(stride):
a = line[i - bpp] if i >= bpp else 0
b = prev[i]
c = prev[i - bpp] if i >= bpp else 0
p = a + b - c
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
pr = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c)
line[i] = (line[i] + pr) & 0xFF
if channels == 4: # RGBA → RGB (白底合成)
rgb = bytearray(w * 3)
for i in range(w):
a4 = line[i * 4 + 3]
for k in range(3):
rgb[i * 3 + k] = (line[i * 4 + k] * a4 + 255 * (255 - a4)) // 255
out += rgb
elif channels == 1: # 灰度 → RGB
rgb = bytearray(w * 3)
for i in range(w):
rgb[i * 3] = rgb[i * 3 + 1] = rgb[i * 3 + 2] = line[i]
out += rgb
else:
out += line
prev = line
return w, h, bytes(out)
def build_pdf(images):
"""images: [(w, h, rgb_bytes)] → PDF 字节串 (每图一页 A4 等比铺满)"""
objs = [] # (num, bytes)
kids = []
def add(body):
objs.append((len(objs) + 1, body))
return len(objs)
for (w, h, rgb) in images:
# 等比缩放铺满 A4
scale = min(PAGE_W / w, PAGE_H / h)
dw, dh = w * scale, h * scale
x0, y0 = (PAGE_W - dw) / 2.0, (PAGE_H - dh) / 2.0
stream = b'q %f 0 0 %f %f %f cm /Im0 Do Q' % (dw, dh, x0, y0)
img_num = add(b'<< /Type /XObject /Subtype /Image /Width %d /Height %d '
b'/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode '
b'/Length %d >>\nstream\n' % (w, h, len(zlib.compress(rgb, 6)))
+ zlib.compress(rgb, 6) + b'\nendstream')
content_num = add(b'<< /Length %d >>\nstream\n' % len(stream) + stream + b'\nendstream')
page_num = add(b'<< /Type /Page /Parent %d 0 R /MediaBox [0 0 %f %f] '
b'/Resources << /XObject << /Im0 %d 0 R >> >> /Contents %d 0 R >>\n'
% (0, PAGE_W, PAGE_H, img_num, content_num))
kids.append(page_num)
pages_num = len(objs) + 1
kids_ref = b' '.join(b'%d 0 R' % k for k in kids)
pages_obj = (b'<< /Type /Pages /Kids [%s] /Count %d >>' % (kids_ref, len(kids)))
objs.append((pages_num, pages_obj))
catalog_num = pages_num + 1
objs.append((catalog_num, b'<< /Type /Catalog /Pages %d 0 R >>' % pages_num))
# 修正 page 里的 Parent 引用
for i, (n, body) in enumerate(objs):
if b'/Parent 0 0 R' in body:
objs[i] = (n, body.replace(b'/Parent 0 0 R', b'/Parent %d 0 R' % pages_num))
out = bytearray(b'%PDF-1.4\n%\xe2\xe3\xcf\xd3\n')
offsets = []
for n, body in objs:
offsets.append(len(out))
out += b'%d 0 obj\n' % n + body + b'\nendobj\n'
xref_pos = len(out)
out += b'xref\n0 %d\n' % (len(objs) + 1)
out += b'0000000000 65535 f \n'
for off in offsets:
out += b'%010d 00000 n \n' % off
out += b'trailer\n<< /Size %d /Root %d 0 R >>\nstartxref\n%d\n%%%%EOF\n' % (len(objs) + 1, catalog_num, xref_pos)
return bytes(out)
def update_meta(series, pdf_name):
meta_path = os.path.join(SERIES_DIR, series + '.meta.json')
with open(meta_path, 'r', encoding='utf-8') as f:
obj = json.load(f)
atts = list(obj.get('attachments', []))
atts = [a for a in atts if a.get('kind') != 'datasheet']
atts.append({'kind': 'datasheet', 'lang': '', 'source': 'manual/%s/%s' % (series, pdf_name), 'model': None})
obj['attachments'] = atts
with open(meta_path, 'w', encoding='utf-8') as f:
json.dump(obj, f, ensure_ascii=False)
def main():
made = 0
for series in sorted(os.listdir(MANUAL_DIR)):
d = os.path.join(MANUAL_DIR, series)
if not os.path.isdir(d):
continue
pngs = sorted(f for f in os.listdir(d) if f.lower().endswith('.png'))
if not pngs:
continue
images = [parse_png(os.path.join(d, p)) for p in pngs]
pdf_name = series + '_manual.pdf'
with open(os.path.join(d, pdf_name), 'wb') as f:
f.write(build_pdf(images))
update_meta(series, pdf_name)
made += 1
print('OK %s: %d 页 → %s' % (series, len(images), pdf_name))
print('完成 %d 个系列 PDF' % made)
print('提示: 替换手册页 PNG 后重跑本脚本更新 PDF, 再【重建全量目录】+ 发布')
if __name__ == '__main__':
main()