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])