From 3f6790edadded78259f8180296c7ede5104fefc6 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Mon, 7 Sep 2026 17:31:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(compose):=20=E6=A0=87=E4=B9=A6docx?= =?UTF-8?q?=E5=90=88=E6=88=90=E6=94=AF=E6=8C=81=E7=AB=A0=E8=8A=82markdown?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E8=A1=8C![alt](url)=E2=80=94=E2=80=94?= =?UTF-8?q?=E4=B8=8B=E8=BD=BDinvoke=5Fmodel=E4=BA=A7=E7=89=A9=E7=9C=9F?= =?UTF-8?q?=E5=9B=BEadd=5Fpicture=E5=B5=8C=E5=85=A5(=E5=B1=85=E4=B8=AD+?= =?UTF-8?q?=E5=9B=BE=E6=B3=A8),=E4=B8=8B=E8=BD=BD=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E6=96=87=E5=AD=97=E5=8D=A0=E4=BD=8D=E8=AF=9A?= =?UTF-8?q?=E5=AE=9E=E9=99=8D=E7=BA=A7(2026-09-07=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E9=9C=80=E6=B1=82:=E7=AB=A0=E8=8A=82=E9=85=8D=E5=9B=BE?= =?UTF-8?q?=E7=94=A8=E6=96=87=E7=94=9F=E5=9B=BE=E7=9C=9F=E5=9B=BE=E9=9D=9E?= =?UTF-8?q?=E5=AD=97=E7=AC=A6=E7=94=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pipeline_bidding/bid_compose_capability.py | 75 +++++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/pipeline_bidding/bid_compose_capability.py b/pipeline_bidding/bid_compose_capability.py index b6ab3d6..34bcda6 100644 --- a/pipeline_bidding/bid_compose_capability.py +++ b/pipeline_bidding/bid_compose_capability.py @@ -35,11 +35,57 @@ async def _workspace_dir(sor, project_id): return '' +def _fetch_image_bytes(url, timeout=30): + """下载配图(章节正文里的 markdown 图片 URL → 本地字节流)。 + + URL 是 invoke_model 产物经 downloadfile2url 落地的本地持久地址 + (/idfile 静态路径,上游 24h 时效 URL 已在推理层落地,这里拿到的是 + 平台自身地址)。http(s) 下载;本地绝对路径直读。失败返回 None(诚实 + 降级:docx 里保留文字说明,不假装成功)。 + """ + try: + u = (url or '').strip() + if not u: + return None + if u.startswith('http://') or u.startswith('https://'): + import urllib.request + req = urllib.request.Request(u, headers={'User-Agent': 'pipeline-bidding'}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = resp.read() + return data if data else None + if u.startswith('/') and os.path.isfile(u): + with open(u, 'rb') as f: + return f.read() + except Exception as e: + logger.warning("compose: 配图下载失败 url=%s: %s", (url or '')[:120], e) + return None + + +# markdown 图片行:![alt](url)(章节写作者按角色 prompt 规范嵌入 invoke_model 产物) +_MD_IMG_RE = None + + +def _parse_md_image(line): + """解析一行 markdown 图片语法,返回 (alt, url) 或 None。""" + global _MD_IMG_RE + if _MD_IMG_RE is None: + import re + _MD_IMG_RE = re.compile(r'^\s*!\[([^\]]*)\]\(([^)\s]+)\)\s*$') + m = _MD_IMG_RE.match(line or '') + if not m: + return None + return m.group(1), m.group(2) + + def _write_docx(path, title, subtitle, chapters): - """用 python-docx 写标书。返回 (ok, page_hint_or_err)。""" + """用 python-docx 写标书。返回 (ok, page_hint_or_err)。 + + 章节正文支持 markdown 图片行 ![alt](url)(写作者经 invoke_model 生成的 + 真实配图):下载后 add_picture 嵌入;下载失败保留文字占位(诚实降级)。 + """ try: from docx import Document - from docx.shared import Pt + from docx.shared import Pt, Inches from docx.enum.text import WD_ALIGN_PARAGRAPH except ImportError: return False, "python-docx 未安装" @@ -72,12 +118,35 @@ def _write_docx(path, title, subtitle, chapters): doc.add_paragraph("%s %s" % (c.get("chapter_no", ""), c.get("title", ""))) doc.add_page_break() + img_ok = img_fail = 0 for c in chapters: doc.add_heading("%s %s" % (c.get("chapter_no", ""), c.get("title", "")), level=1) for line in (c.get("content") or "").split("\n"): t = line.rstrip() if not t: continue + img = _parse_md_image(t) + if img: + alt, url = img + data = _fetch_image_bytes(url) + if data: + import io + pic_p = doc.add_paragraph() + pic_p.alignment = WD_ALIGN_PARAGRAPH.CENTER + try: + pic_p.add_run().add_picture(io.BytesIO(data), + width=Inches(5.5)) + if alt: + cap = doc.add_paragraph(alt) + cap.alignment = WD_ALIGN_PARAGRAPH.CENTER + img_ok += 1 + except Exception as e: + doc.add_paragraph("[配图 %s 嵌入失败:%s]" % (alt or url[:60], str(e)[:80])) + img_fail += 1 + else: + doc.add_paragraph("[配图缺失(下载失败):%s %s]" % (alt or "", url[:80])) + img_fail += 1 + continue if t.startswith("### "): doc.add_heading(t[4:], level=3) elif t.startswith("## "): @@ -90,6 +159,8 @@ def _write_docx(path, title, subtitle, chapters): doc.add_paragraph(t) doc.add_page_break() doc.save(path) + if img_ok or img_fail: + logger.info("compose: docx 配图嵌入 成功%d 失败%d", img_ok, img_fail) return True, "" except Exception as e: return False, "%s: %s" % (type(e).__name__, str(e)[:200])