Files
2026-07-13 12:29:52 +08:00

862 lines
35 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)
text = re.sub(r"<(https?://[^>]+)>", 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'<w:jc w:val="{align}"/>' if align else ""
escaped = html.escape(text)
return (
"<w:p>"
f"<w:pPr>{align_xml}<w:spacing w:after=\"0\" w:line=\"{line_twips}\" w:lineRule=\"exact\"/></w:pPr>"
"<w:r>"
f"<w:rPr><w:rFonts w:ascii=\"{font}\" w:hAnsi=\"{font}\" w:eastAsia=\"{font}\"/>"
f"<w:color w:val=\"{BLACK_RGB}\"/>"
f"<w:sz w:val=\"{size_half_points}\"/><w:szCs w:val=\"{size_half_points}\"/></w:rPr>"
f"<w:t xml:space=\"preserve\">{escaped}</w:t>"
"</w:r>"
"</w:p>"
)
def page_break_xml() -> str:
return '<w:p><w:r><w:br w:type="page"/></w:r></w:p>'
def page_field_runs_xml() -> str:
return (
'<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/>'
f'<w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr>'
'<w:fldChar w:fldCharType="begin"/></w:r>'
'<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/>'
f'<w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr>'
'<w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>'
'<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/>'
f'<w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr>'
'<w:fldChar w:fldCharType="separate"/></w:r>'
'<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/>'
f'<w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr>'
'<w:t>1</w:t></w:r>'
'<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/>'
f'<w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr>'
'<w:fldChar w:fldCharType="end"/></w:r>'
)
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"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:tbl>
<w:tblPr>
<w:tblW w:w="5000" w:type="pct"/>
<w:tblBorders>
<w:top w:val="nil"/><w:left w:val="nil"/><w:bottom w:val="nil"/><w:right w:val="nil"/><w:insideH w:val="nil"/><w:insideV w:val="nil"/>
</w:tblBorders>
</w:tblPr>
<w:tr>
<w:tc>
<w:p>
<w:pPr><w:jc w:val="left"/><w:spacing w:after="0" w:line="240" w:lineRule="exact"/></w:pPr>
<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/><w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr><w:t xml:space="preserve">{escaped}</w:t></w:r>
</w:p>
</w:tc>
<w:tc>
<w:p>
<w:pPr><w:jc w:val="right"/><w:spacing w:after="0" w:line="240" w:lineRule="exact"/></w:pPr>
<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/><w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr><w:t xml:space="preserve">第 </w:t></w:r>
{page_field_runs_xml()}
<w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/><w:color w:val="{BLACK_RGB}"/><w:sz w:val="16"/><w:szCs w:val="16"/></w:rPr><w:t xml:space="preserve"> 页</w:t></w:r>
</w:p>
</w:tc>
</w:tr>
</w:tbl>
</w:hdr>"""
def minimal_docx(out_path: Path, body_xml: str, header_text: str | None = None, start_page: int = 1) -> None:
content_types = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
<Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/>
</Types>"""
rels = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>"""
header_rel = (
'<Relationship Id="rIdHeader1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/>'
if header_text
else ""
)
doc_rels = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{header_rel}</Relationships>"""
styles = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
<w:name w:val="Normal"/>
<w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun" w:eastAsia="SimSun"/><w:color w:val="{BLACK_RGB}"/><w:sz w:val="21"/></w:rPr>
</w:style>
</w:styles>"""
document = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<w:body>
{body_xml}
<w:sectPr>
{'<w:headerReference w:type="default" r:id="rIdHeader1"/>' if header_text else ''}
{'<w:pgNumType w:start="' + str(start_page) + '"/>' if start_page != 1 else ''}
<w:pgSz w:w="11906" w:h="16838"/>
<w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1418" w:header="283" w:footer="283" w:gutter="0"/>
</w:sectPr>
</w:body>
</w:document>"""
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"<w:hyperlink\b[^>]*>", "", xml)
xml = xml.replace("</w:hyperlink>", "")
xml = re.sub(r"<w:color\b[^>]*/>", f'<w:color w:val="{BLACK_RGB}"/>', xml)
def ensure_rpr_color(match: re.Match[str]) -> str:
value = match.group(0)
if "<w:color" in value:
return value
return value.replace("</w:rPr>", f'<w:color w:val="{BLACK_RGB}"/></w:rPr>')
xml = re.sub(r"<w:rPr\b[^>]*>.*?</w:rPr>", ensure_rpr_color, xml, flags=re.S)
xml = re.sub(r"<w:r>(?!<w:rPr>)", f'<w:r><w:rPr><w:color w:val="{BLACK_RGB}"/></w:rPr>', 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*<Relationship\b[^>]*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'<Override PartName="/{header_part}" '
'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/>'
)
if f'PartName="/{header_part}"' not in text:
text = text.replace("</Types>", f"{override}</Types>")
data = text.encode("utf-8")
elif item.filename == "word/_rels/document.xml.rels":
text = data.decode("utf-8")
relationship = (
f'<Relationship Id="{rel_id}" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" '
f'Target="{header_target}"/>'
)
text = text.replace("</Relationships>", f"{relationship}</Relationships>")
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(
"<w:document ",
'<w:document xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ',
1,
)
header_ref = f'<w:headerReference w:type="default" r:id="{rel_id}"/>'
if "<w:headerReference" in text:
text = re.sub(r"<w:headerReference\b[^>]*/>", header_ref, text, count=1)
else:
text = re.sub(r"(<w:sectPr\b[^>]*>)", 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('</w:r></w:p>', '<w:br w:type="page"/></w:r></w:p>')
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("<!--") and "截图" in stripped:
stripped = "【截图预留:请在此处插入当前功能页面或操作结果截图。】"
if stripped.startswith("|") and stripped.endswith("|"):
table_buf.append(parse_table_line(stripped))
continue
flush_table()
if not stripped:
continue
image_match = re.search(r"!\[[^\]]*\]\(([^)]+)\)", stripped)
if image_match:
add_image(document, (base_dir / image_match.group(1)).resolve())
continue
heading = re.match(r"^(#{1,4})\s+(.+)$", stripped)
if heading:
level = min(len(heading.group(1)), 3)
p = document.add_heading(heading.group(2), level=level)
for run in p.runs:
try:
run.font.color.rgb = RGBColor(0, 0, 0)
except Exception:
pass
continue
if re.match(r"^[-*+]\s+", stripped):
p = document.add_paragraph(style="List Bullet")
run = p.add_run(re.sub(r"^[-*+]\s+", "", stripped))
set_run_font(run, "SimSun", 10.5)
continue
if re.match(r"^\d+\.\s+", stripped):
p = document.add_paragraph(style="List Number")
run = p.add_run(re.sub(r"^\d+\.\s+", "", stripped))
set_run_font(run, "SimSun", 10.5)
continue
p = document.add_paragraph()
run = p.add_run(stripped)
set_run_font(run, "SimSun", 10.5)
flush_table()
force_black_document(document)
document.save(out_path)
def pandoc_available() -> bool:
return shutil.which("pandoc") is not None
def build_with_pandoc(md_path: Path, out_path: Path, code_mode: bool = False) -> None:
if not pandoc_available():
raise RuntimeError("python-docx is unavailable and pandoc is not installed")
source = md_path
tmp_name: str | None = None
original_text = md_path.read_text(encoding="utf-8")
text = original_text
text = re.sub(r"```text\s*\nSTOP_FOR_USER\n.*?```", "", text, flags=re.S)
text = re.sub(r"<!--[^>]*截图[^>]*-->", "【截图预留:请在此处插入当前功能页面或操作结果截图。】", 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()