#!/usr/bin/env python3 """Build final DOCX/TXT files from confirmed Markdown drafts.""" from __future__ import annotations import argparse import html import re import shutil import subprocess import tempfile import zipfile from pathlib import Path from typing import Any from common import ensure_dir, read_json, safe_filename try: from docx import Document from docx.enum.section import WD_SECTION from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.shared import Cm, Inches, Pt, RGBColor DOCX_AVAILABLE = True except Exception: DOCX_AVAILABLE = False BLACK_RGB = "000000" def strip_markdown_links(text: str) -> str: text = re.sub(r"(?]+)>", r"\1", text) return text def parse_application_lines(md_path: Path) -> tuple[list[str], list[str]]: lines = md_path.read_text(encoding="utf-8").splitlines() fields = [line.strip() for line in lines if line.strip().startswith("➤")] warnings = [line for line in fields if "待用户确认" in line] return fields, warnings def parse_application_field(md_path: Path, field_name: str) -> str: if not md_path.exists(): return "" prefix = f"➤{field_name}:" for line in md_path.read_text(encoding="utf-8").splitlines(): stripped = line.strip() if stripped.startswith(prefix): return stripped[len(prefix) :].strip() return "" def application_version(draft_dir: Path) -> str: version = parse_application_field(draft_dir / "申请表信息.md", "版本号") if "待用户确认" in version: return "" return version def application_software_name(draft_dir: Path) -> str: name = parse_application_field(draft_dir / "申请表信息.md", "软件全称") if "待用户确认" in name: return "" return name def write_application_txt(draft_dir: Path, out_dir: Path) -> tuple[Path | None, list[str]]: md_path = draft_dir / "申请表信息.md" if not md_path.exists(): return None, ["缺少草稿/申请表信息.md"] fields, warnings = parse_application_lines(md_path) out_path = out_dir / "申请表信息.txt" out_path.write_text("\n".join(fields) + "\n", encoding="utf-8") return out_path, warnings def read_json_if_exists(path: Path) -> dict[str, Any]: if not path.exists(): return {} return read_json(path) def confirmation_issues(workdir: Path) -> list[str]: draft_dir = workdir / "草稿" issues: list[str] = [] business = read_json_if_exists(draft_dir / "业务理解.json") if not business or not business.get("user_confirmed"): issues.append("业务理解尚未确认:请确认 草稿/业务理解.md 后记录 `business` 门禁") selection = read_json_if_exists(draft_dir / "代码文件选择.json") if not selection or not selection.get("user_confirmed"): issues.append("代码文件选择尚未确认:请确认 草稿/代码文件选择.json 后记录 `code-selection` 门禁") screenshot = read_json_if_exists(workdir / "截图方式确认.json") if not screenshot.get("screenshot_method_confirmed"): issues.append("截图方式尚未确认:请选择截图方式后记录 `screenshot-method` 门禁") app_md = draft_dir / "申请表信息.md" if app_md.exists(): _, warnings = parse_application_lines(app_md) if warnings: issues.append("申请表信息仍包含“待用户确认”字段") else: issues.append("缺少 草稿/申请表信息.md") app_confirmation = read_json_if_exists(draft_dir / "申请表字段确认.json") if not app_confirmation.get("application_fields_confirmed"): issues.append("申请表字段尚未确认:请补全字段后记录 `application-fields` 门禁") markdown_confirmation = read_json_if_exists(draft_dir / "最终生成确认.json") if not markdown_confirmation.get("markdown_confirmed"): issues.append("Markdown 草稿尚未最终确认:请确认全部草稿后记录 `markdown` 门禁") return issues def parse_code_pages(md_path: Path) -> list[tuple[int, list[str]]]: pages: list[tuple[int, list[str]]] = [] current_no: int | None = None current_lines: list[str] = [] in_fence = False for raw in md_path.read_text(encoding="utf-8").splitlines(): page_match = re.match(r"^##\s+第\s*(\d+)\s*页", raw.strip()) if page_match: if current_no is not None: pages.append((current_no, current_lines)) current_no = int(page_match.group(1)) current_lines = [] in_fence = False continue if raw.strip().startswith("```"): in_fence = not in_fence continue if current_no is not None and in_fence: current_lines.append(raw) if current_no is not None: pages.append((current_no, current_lines)) return pages def set_run_font(run: Any, name: str, size_pt: float) -> None: run.font.name = name run.font.size = Pt(size_pt) try: run.font.color.rgb = RGBColor(0, 0, 0) except Exception: pass try: run._element.rPr.rFonts.set(qn("w:eastAsia"), name) except Exception: pass def set_normal_font(document: Any, name: str = "SimSun", size_pt: float = 10.5) -> None: style = document.styles["Normal"] style.font.name = name style.font.size = Pt(size_pt) try: style.font.color.rgb = RGBColor(0, 0, 0) except Exception: pass try: style._element.rPr.rFonts.set(qn("w:eastAsia"), name) except Exception: pass def set_style_black(document: Any) -> None: for style_name in ("Normal", "Heading 1", "Heading 2", "Heading 3", "List Bullet", "List Number"): try: document.styles[style_name].font.color.rgb = RGBColor(0, 0, 0) except Exception: pass def force_black_document(document: Any) -> None: set_style_black(document) containers = [document] for section in document.sections: containers.extend([section.header, section.footer]) for container in containers: for paragraph in container.paragraphs: for run in paragraph.runs: try: run.font.color.rgb = RGBColor(0, 0, 0) except Exception: pass for table in container.tables: for row in table.rows: for cell in row.cells: for paragraph in cell.paragraphs: for run in paragraph.runs: try: run.font.color.rgb = RGBColor(0, 0, 0) except Exception: pass def configure_a4(document: Any) -> None: section = document.sections[0] section.page_width = Cm(21) section.page_height = Cm(29.7) section.top_margin = Cm(2.54) section.bottom_margin = Cm(2.54) section.left_margin = Cm(3.17) section.right_margin = Cm(2.54) def configure_code_a4(document: Any) -> None: section = document.sections[0] section.page_width = Cm(21) section.page_height = Cm(29.7) section.top_margin = Cm(2.0) section.bottom_margin = Cm(2.0) section.left_margin = Cm(2.5) section.right_margin = Cm(2.0) def add_page_field(paragraph: Any) -> None: begin = OxmlElement("w:fldChar") begin.set(qn("w:fldCharType"), "begin") instr = OxmlElement("w:instrText") instr.set("{http://www.w3.org/XML/1998/namespace}space", "preserve") instr.text = " PAGE " separate = OxmlElement("w:fldChar") separate.set(qn("w:fldCharType"), "separate") result = OxmlElement("w:t") result.text = "1" end = OxmlElement("w:fldChar") end.set(qn("w:fldCharType"), "end") for element in (begin, instr, separate, result, end): run = paragraph.add_run() run._r.append(element) set_run_font(run, "SimSun", 8) def set_code_header(document: Any, software_name: str, version: str) -> None: section = document.sections[0] section.header.is_linked_to_previous = False header = section.header header.paragraphs[0].text = "" if header.paragraphs else None # Build a two-column header: software name on the left, page number on the right. table = header.add_table(rows=1, cols=2, width=Cm(17.5)) table.autofit = True left_cell = table.rows[0].cells[0] right_cell = table.rows[0].cells[1] left_para = left_cell.paragraphs[0] left_para.alignment = WD_ALIGN_PARAGRAPH.LEFT left_para.paragraph_format.space_before = Pt(0) left_para.paragraph_format.space_after = Pt(0) left_para.paragraph_format.line_spacing_rule = WD_LINE_SPACING.EXACTLY left_para.paragraph_format.line_spacing = Pt(12) left_run = left_para.add_run(f"{software_name} {version}") set_run_font(left_run, "SimSun", 8) right_para = right_cell.paragraphs[0] right_para.alignment = WD_ALIGN_PARAGRAPH.RIGHT right_para.paragraph_format.space_before = Pt(0) right_para.paragraph_format.space_after = Pt(0) right_para.paragraph_format.line_spacing_rule = WD_LINE_SPACING.EXACTLY right_para.paragraph_format.line_spacing = Pt(12) prefix = right_para.add_run("第 ") set_run_font(prefix, "SimSun", 8) add_page_field(right_para) suffix = right_para.add_run(" 页") set_run_font(suffix, "SimSun", 8) # Remove borders from the header table for cell in table.rows[0].cells: tc_pr = cell._tc.get_or_add_tcPr() tc_borders = OxmlElement("w:tcBorders") for border_name in ("top", "left", "bottom", "right"): border = OxmlElement(f"w:{border_name}") border.set(qn("w:val"), "nil") tc_borders.append(border) tc_pr.append(tc_borders) def build_code_docx_python(md_path: Path, out_path: Path, software_name: str, version: str) -> None: pages = parse_code_pages(md_path) if not pages: raise RuntimeError(f"No code pages parsed from {md_path}") document = Document() configure_code_a4(document) set_normal_font(document, "Consolas", 7.2) set_style_black(document) set_code_header(document, software_name, version) # 后30页文档页码从31开始,实现前后文档连续编号1-60 start_page_no = pages[0][0] if pages else 1 if start_page_no != 1: pg_num_type = OxmlElement("w:pgNumType") pg_num_type.set(qn("w:start"), str(start_page_no)) document.sections[0]._sectPr.append(pg_num_type) for index, (page_no, lines) in enumerate(pages): for line in lines: p = document.add_paragraph() p.paragraph_format.space_before = Pt(0) p.paragraph_format.space_after = Pt(0) p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.EXACTLY p.paragraph_format.line_spacing = Pt(14) run = p.add_run(line if line else " ") set_run_font(run, "Consolas", 7.2) if index != len(pages) - 1: # 嵌入式分页符:避免 add_page_break() 产生多余空段落导致空白页 run = p.add_run() br = OxmlElement('w:br') br.set(qn('w:type'), 'page') run._r.append(br) force_black_document(document) document.save(out_path) def paragraph_xml(text: str, font: str = "SimSun", size_half_points: int = 21, align: str | None = None, line_twips: int = 240) -> str: align_xml = f'' if align else "" escaped = html.escape(text) return ( "" f"{align_xml}" "" f"" f"" f"" f"{escaped}" "" "" ) def page_break_xml() -> str: return '' def page_field_runs_xml() -> str: return ( '' f'' '' '' f'' ' PAGE ' '' f'' '' '' f'' '1' '' f'' '' ) def header_xml(header_text: str) -> str: """Build a two-column header: software name left, page number right.""" escaped = html.escape(header_text) # Use a borderless table for left/right alignment in header return f""" {escaped} {page_field_runs_xml()} """ def minimal_docx(out_path: Path, body_xml: str, header_text: str | None = None, start_page: int = 1) -> None: content_types = """ """ rels = """ """ header_rel = ( '' if header_text else "" ) doc_rels = f""" {header_rel}""" styles = f""" """ document = f""" {body_xml} {'' if header_text else ''} {'' if start_page != 1 else ''} """ with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: zf.writestr("[Content_Types].xml", content_types) zf.writestr("_rels/.rels", rels) zf.writestr("word/_rels/document.xml.rels", doc_rels) zf.writestr("word/styles.xml", styles) zf.writestr("word/document.xml", document) if header_text: zf.writestr("word/header1.xml", header_xml(header_text)) def force_black_xml(xml: str) -> str: xml = re.sub(r"]*>", "", xml) xml = xml.replace("", "") xml = re.sub(r"]*/>", f'', xml) def ensure_rpr_color(match: re.Match[str]) -> str: value = match.group(0) if "", f'') xml = re.sub(r"]*>.*?", ensure_rpr_color, xml, flags=re.S) xml = re.sub(r"(?!)", f'', xml) return xml def normalize_docx_text_color(docx_path: Path) -> None: tmp_path = docx_path.with_suffix(docx_path.suffix + ".tmp") color_xml_parts = ( "word/document.xml", "word/styles.xml", "word/numbering.xml", "word/header", "word/footer", ) with zipfile.ZipFile(docx_path, "r") as src, zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as dst: for item in src.infolist(): data = src.read(item.filename) if item.filename.endswith(".xml") and item.filename.startswith(color_xml_parts): text = data.decode("utf-8") data = force_black_xml(text).encode("utf-8") elif item.filename.endswith(".rels"): text = data.decode("utf-8", errors="ignore") if "hyperlink" in text: text = re.sub(r'\s*]*Type="[^"]*/hyperlink"[^>]*/>', "", text) data = text.encode("utf-8") dst.writestr(item, data) tmp_path.replace(docx_path) def next_header_part(names: set[str]) -> tuple[str, str]: index = 1 while f"word/header{index}.xml" in names: index += 1 return f"word/header{index}.xml", f"header{index}.xml" def unique_relationship_id(rels_xml: str, base: str = "rIdManualHeader") -> str: if f'Id="{base}"' not in rels_xml: return base index = 2 while f'Id="{base}{index}"' in rels_xml: index += 1 return f"{base}{index}" def add_header_to_existing_docx(docx_path: Path, header_text: str) -> None: """Add the same two-column header used by code materials to an existing DOCX.""" tmp_path = docx_path.with_suffix(docx_path.suffix + ".tmp") with zipfile.ZipFile(docx_path, "r") as src: names = set(src.namelist()) header_part, header_target = next_header_part(names) rels_xml = src.read("word/_rels/document.xml.rels").decode("utf-8") rel_id = unique_relationship_id(rels_xml) with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as dst: for item in src.infolist(): data = src.read(item.filename) if item.filename == "[Content_Types].xml": text = data.decode("utf-8") override = ( f'' ) if f'PartName="/{header_part}"' not in text: text = text.replace("", f"{override}") data = text.encode("utf-8") elif item.filename == "word/_rels/document.xml.rels": text = data.decode("utf-8") relationship = ( f'' ) text = text.replace("", f"{relationship}") data = text.encode("utf-8") elif item.filename == "word/document.xml": text = data.decode("utf-8") if "xmlns:r=" not in text: text = text.replace( "' if "]*/>", header_ref, text, count=1) else: text = re.sub(r"(]*>)", rf"\1{header_ref}", text, count=1) data = text.encode("utf-8") dst.writestr(item, data) dst.writestr(header_part, header_xml(header_text)) tmp_path.replace(docx_path) def build_code_docx_ooxml(md_path: Path, out_path: Path, software_name: str, version: str) -> None: pages = parse_code_pages(md_path) if not pages: raise RuntimeError(f"No code pages parsed from {md_path}") start_page_no = pages[0][0] if pages else 1 body: list[str] = [] for index, (page_no, lines) in enumerate(pages): for line in lines: body.append(paragraph_xml(line if line else " ", font="Consolas", size_half_points=14, line_twips=280)) if index != len(pages) - 1: # 嵌入式分页符:嵌入最后一段的 run 避免多余空段落 last = body.pop() last = last.replace('', '') body.append(last) minimal_docx(out_path, "\n".join(body), header_text=f"{software_name} {version}", start_page=start_page_no) def add_markdown_table(document: Any, rows: list[list[str]]) -> None: if not rows: return table = document.add_table(rows=1, cols=len(rows[0])) table.style = "Table Grid" for idx, text in enumerate(rows[0]): table.rows[0].cells[idx].text = strip_markdown_links(text) for row in rows[1:]: cells = table.add_row().cells for idx, text in enumerate(row[: len(cells)]): cells[idx].text = strip_markdown_links(text) def parse_table_line(line: str) -> list[str]: return [cell.strip() for cell in line.strip().strip("|").split("|")] def add_image(document: Any, image_path: Path) -> None: if not image_path.exists(): p = document.add_paragraph() run = p.add_run(f"[截图缺失:{image_path}]") set_run_font(run, "SimSun", 10.5) return try: document.add_picture(str(image_path), width=Inches(5.8)) except Exception: p = document.add_paragraph() run = p.add_run(f"[截图无法插入:{image_path}]") set_run_font(run, "SimSun", 10.5) def build_manual_docx_python(md_path: Path, out_path: Path, base_dir: Path, software_name: str, version: str) -> None: document = Document() configure_a4(document) set_normal_font(document, "SimSun", 10.5) set_style_black(document) set_code_header(document, software_name, version) lines = md_path.read_text(encoding="utf-8").splitlines() table_buf: list[list[str]] = [] in_fence = False def flush_table() -> None: nonlocal table_buf if table_buf: data = [row for row in table_buf if not all(re.fullmatch(r":?-{3,}:?", cell) for cell in row)] add_markdown_table(document, data) table_buf = [] for line in lines: stripped = line.strip() stripped = strip_markdown_links(stripped) if stripped.startswith("```"): flush_table() in_fence = not in_fence continue if in_fence: continue if stripped.startswith("", "【截图预留:请在此处插入当前功能页面或操作结果截图。】", text) text = strip_markdown_links(text) if code_mode: text = re.sub(r"(?=^##\s+第\s*\d+\s*页)", r"\n\\newpage\n", text, flags=re.M) if code_mode or "STOP_FOR_USER" in original_text: with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as tmp: tmp.write(text) tmp_name = tmp.name source = Path(tmp_name) try: subprocess.run(["pandoc", "-f", "markdown", "-t", "docx", str(source), "-o", str(out_path)], check=True) finally: if tmp_name: Path(tmp_name).unlink(missing_ok=True) def build_code_docx(md_path: Path, out_path: Path, software_name: str, version: str) -> None: if DOCX_AVAILABLE: build_code_docx_python(md_path, out_path, software_name, version) else: build_code_docx_ooxml(md_path, out_path, software_name, version) normalize_docx_text_color(out_path) def build_manual_docx(md_path: Path, out_path: Path, base_dir: Path, software_name: str, version: str) -> None: if DOCX_AVAILABLE: build_manual_docx_python(md_path, out_path, base_dir, software_name, version) else: build_with_pandoc(md_path, out_path, code_mode=False) add_header_to_existing_docx(out_path, f"{software_name} {version}") normalize_docx_text_color(out_path) def run_command(command: list[str], cwd: Path | None = None, timeout: int = 60) -> tuple[int, str]: try: completed = subprocess.run(command, cwd=cwd, text=True, capture_output=True, timeout=timeout) return completed.returncode, (completed.stdout + completed.stderr).strip() except Exception as exc: return 99, str(exc) def docx_checks(skill_dir: Path, outputs: list[Path]) -> list[str]: notes: list[str] = [] env_script = skill_dir / "vendor/docx-toolkit/scripts/env_check.sh" preview_script = skill_dir / "vendor/docx-toolkit/scripts/docx_preview.sh" if env_script.exists(): code, output = run_command(["bash", str(env_script)], cwd=env_script.parent.parent, timeout=30) status = "READY" if code == 0 else "NOT READY" first_lines = "\n".join(output.splitlines()[:12]) notes.append(f"DOCX env: {status}\n\n```text\n{first_lines}\n```") else: notes.append("DOCX env: vendor script missing") if preview_script.exists(): for out in outputs: code, output = run_command(["bash", str(preview_script), str(out)], timeout=45) first_lines = "\n".join(output.splitlines()[:8]) notes.append(f"Preview {out.name}: exit={code}\n\n```text\n{first_lines}\n```") return notes def build_all(workdir: Path, software_name: str, version: str, skip_preview: bool) -> dict[str, Any]: workdir = ensure_dir(workdir) draft_dir = workdir / "草稿" final_dir = ensure_dir(workdir / "正式资料") app_name = application_software_name(draft_dir) app_version = application_version(draft_dir) final_software_name = app_name or software_name final_version = app_version or version safe_name = safe_filename(final_software_name) outputs: list[Path] = [] warnings: list[str] = [] if app_name and app_name != software_name: warnings.append(f"命令参数软件名称为 {software_name},正式资料已按申请表信息软件名称 {app_name} 生成") if app_version and app_version != version: warnings.append(f"命令参数版本号为 {version},正式资料已按申请表信息版本号 {app_version} 生成") screenshot_confirmation = read_json_if_exists(workdir / "截图方式确认.json") screenshot_method = screenshot_confirmation.get("screenshot_method") screenshot_manifest = workdir / "截图/截图清单.json" if screenshot_method == "skip": warnings.append("用户选择暂不截图;操作手册已保留截图预留位置") elif screenshot_method and not screenshot_manifest.exists(): warnings.append("操作手册截图未生成或未插入;操作手册应保留截图预留位置") elif screenshot_manifest.exists(): screenshots = read_json_if_exists(screenshot_manifest).get("screenshots") or [] if not screenshots: warnings.append("操作手册截图清单为空;操作手册应保留截图预留位置") app_txt, app_warnings = write_application_txt(draft_dir, final_dir) if app_txt: outputs.append(app_txt) warnings.extend(app_warnings) code_specs = [ ("代码-前30页.md", f"{safe_name}-代码(前30页).docx"), ("代码-后30页.md", f"{safe_name}-代码(后30页).docx"), ("代码-全部.md", f"{safe_name}-代码(全部).docx"), ] for md_name, docx_name in code_specs: md_path = draft_dir / md_name if md_path.exists(): out_path = final_dir / docx_name build_code_docx(md_path, out_path, final_software_name, final_version) outputs.append(out_path) manual_md = draft_dir / "操作手册.md" if manual_md.exists(): manual_out = final_dir / f"{safe_name}_操作手册.docx" manual_source = manual_md tmp_manual: Path | None = None if app_name and app_name != software_name: text = manual_md.read_text(encoding="utf-8").replace(software_name, app_name) with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as tmp: tmp.write(text) tmp_manual = Path(tmp.name) manual_source = tmp_manual try: build_manual_docx(manual_source, manual_out, draft_dir, final_software_name, final_version) finally: if tmp_manual: tmp_manual.unlink(missing_ok=True) outputs.append(manual_out) else: warnings.append("缺少草稿/操作手册.md") skill_dir = Path(__file__).resolve().parents[1] notes = [] if skip_preview else docx_checks(skill_dir, [p for p in outputs if p.suffix.lower() == ".docx"]) report = write_report(final_dir, outputs, warnings, notes) return {"outputs": [str(p) for p in outputs], "warnings": warnings, "report": str(report)} def write_report(workdir: Path, outputs: list[Path], warnings: list[str], notes: list[str]) -> Path: report = workdir / "生成报告.md" lines = ["# 生成报告", "", "## 输出文件", ""] for path in outputs: size = path.stat().st_size if path.exists() else 0 lines.append(f"- `{path.name}` ({size} bytes)") lines.extend(["", "## 警告", ""]) if warnings: lines.extend(f"- {warning}" for warning in warnings) else: lines.append("- 无") lines.extend(["", "## DOCX 校验", ""]) if notes: lines.extend(notes) else: lines.append("- 已跳过预览校验") report.write_text("\n".join(lines) + "\n", encoding="utf-8") return report def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--workdir", default="软件著作权申请资料") parser.add_argument("--software-name", required=True) parser.add_argument("--version", default="V1.0") parser.add_argument("--skip-preview", action="store_true") args = parser.parse_args() workdir = Path(args.workdir) issues = confirmation_issues(workdir) if issues: print("STOP_FOR_USER") print("NEXT_ACTION: 正式 Word/TXT 生成前必须完成以下确认:") for issue in issues: print(f"- {issue}") raise SystemExit(2) result = build_all(workdir, args.software_name, args.version, args.skip_preview) print(f"OK final materials: {Path(args.workdir) / '正式资料'}") for output in result["outputs"]: print(output) if result["warnings"]: print("Warnings:") for warning in result["warnings"]: print(f"- {warning}") print(f"Report: {result['report']}") if __name__ == "__main__": main()