# -*- 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()