项目文件夹

文件
2026-07-13 12:29:52 +08:00

862 行
35 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/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()