Files
ax_hub_mcp_tool/docs/tool-guide/build_tool_guides_docx.py
jade e998127fa5
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 1m3s
feat: implement MCI session fetching using employee number
2026-08-19 16:41:13 +09:00

634 lines
22 KiB
Python

from __future__ import annotations
import re
from pathlib import Path
from docx import Document
from docx.enum.section import WD_SECTION
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_TABLE_ALIGNMENT
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_LINE_SPACING
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Inches, Pt, RGBColor
ROOT = Path(__file__).resolve().parent
OUTPUT_DIR = ROOT / "docx-professional"
SOURCES = [
(
ROOT / "01-Tool-개발환경-가이드.md",
"개발 환경 가이드",
"Tool Pod 개발 환경과 내부망 반입 기준",
("개발 환경", "실행·검증", "내부망 반입"),
),
(
ROOT / "02-Tool-설계-가이드.md",
"Tool 설계 가이드",
"명명·구조·Schema·MCI·Glow HTTP 설계 기준",
("명명·패키지", "Input·Output Schema", "MCI·Glow HTTP"),
),
(
ROOT / "03-Tool-개발가이드-1차.md",
"Tool 개발 가이드 1차본",
"신규 Tool 구현·검증·완료 절차",
("Scaffold", "구현·연계", "테스트·완료"),
),
]
FONT_BODY = "맑은 고딕"
FONT_CODE = "Consolas"
BLUE = "2E74B5"
DARK_BLUE = "1F4D78"
NAVY = "0B2545"
INK = "24364B"
MUTED = "667085"
LIGHT_BLUE = "E8EEF5"
LIGHT_GRAY = "F4F6F9"
SOFT_BLUE = "F1F6FB"
TABLE_ALT = "F8FAFC"
BORDER = "C9D2DE"
WHITE = "FFFFFF"
GOLD = "B88928"
CONTENT_DXA = 9360
TABLE_INDENT_DXA = 120
def set_run_font(run, name=FONT_BODY, size=None, color=None, bold=None, italic=None):
run.font.name = name
run._element.get_or_add_rPr().rFonts.set(qn("w:ascii"), name)
run._element.get_or_add_rPr().rFonts.set(qn("w:hAnsi"), name)
run._element.get_or_add_rPr().rFonts.set(qn("w:eastAsia"), name)
if size is not None:
run.font.size = Pt(size)
if color is not None:
run.font.color.rgb = RGBColor.from_string(color)
if bold is not None:
run.bold = bold
if italic is not None:
run.italic = italic
def set_cell_shading(cell, fill):
tc_pr = cell._tc.get_or_add_tcPr()
shd = tc_pr.find(qn("w:shd"))
if shd is None:
shd = OxmlElement("w:shd")
tc_pr.append(shd)
shd.set(qn("w:fill"), fill)
def set_cell_margins(cell, top=80, start=120, bottom=80, end=120):
tc = cell._tc
tc_pr = tc.get_or_add_tcPr()
tc_mar = tc_pr.first_child_found_in("w:tcMar")
if tc_mar is None:
tc_mar = OxmlElement("w:tcMar")
tc_pr.append(tc_mar)
for edge, value in (("top", top), ("start", start), ("bottom", bottom), ("end", end)):
node = tc_mar.find(qn(f"w:{edge}"))
if node is None:
node = OxmlElement(f"w:{edge}")
tc_mar.append(node)
node.set(qn("w:w"), str(value))
node.set(qn("w:type"), "dxa")
def set_table_geometry(table, widths):
total = sum(widths)
if total != CONTENT_DXA:
widths[-1] += CONTENT_DXA - total
table.alignment = WD_TABLE_ALIGNMENT.LEFT
table.autofit = False
tbl_pr = table._tbl.tblPr
tbl_w = tbl_pr.find(qn("w:tblW"))
if tbl_w is None:
tbl_w = OxmlElement("w:tblW")
tbl_pr.append(tbl_w)
tbl_w.set(qn("w:w"), str(CONTENT_DXA))
tbl_w.set(qn("w:type"), "dxa")
tbl_ind = tbl_pr.find(qn("w:tblInd"))
if tbl_ind is None:
tbl_ind = OxmlElement("w:tblInd")
tbl_pr.append(tbl_ind)
tbl_ind.set(qn("w:w"), str(TABLE_INDENT_DXA))
tbl_ind.set(qn("w:type"), "dxa")
grid = table._tbl.tblGrid
for child in list(grid):
grid.remove(child)
for width in widths:
col = OxmlElement("w:gridCol")
col.set(qn("w:w"), str(width))
grid.append(col)
for row in table.rows:
for idx, cell in enumerate(row.cells):
cell.width = Inches(widths[idx] / 1440)
tc_w = cell._tc.get_or_add_tcPr().find(qn("w:tcW"))
if tc_w is None:
tc_w = OxmlElement("w:tcW")
cell._tc.get_or_add_tcPr().append(tc_w)
tc_w.set(qn("w:w"), str(widths[idx]))
tc_w.set(qn("w:type"), "dxa")
set_cell_margins(cell)
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
def repeat_table_header(row):
tr_pr = row._tr.get_or_add_trPr()
header = OxmlElement("w:tblHeader")
header.set(qn("w:val"), "true")
tr_pr.append(header)
def add_page_number(paragraph):
run = paragraph.add_run()
begin = OxmlElement("w:fldChar")
begin.set(qn("w:fldCharType"), "begin")
instruction = OxmlElement("w:instrText")
instruction.set(qn("xml:space"), "preserve")
instruction.text = " PAGE "
separate = OxmlElement("w:fldChar")
separate.set(qn("w:fldCharType"), "separate")
text = OxmlElement("w:t")
text.text = "1"
end = OxmlElement("w:fldChar")
end.set(qn("w:fldCharType"), "end")
run._r.extend([begin, instruction, separate, text, end])
set_run_font(run, size=9, color=MUTED)
def add_bottom_border(paragraph, color=BORDER, size="6"):
p_pr = paragraph._p.get_or_add_pPr()
p_bdr = p_pr.find(qn("w:pBdr"))
if p_bdr is None:
p_bdr = OxmlElement("w:pBdr")
p_pr.append(p_bdr)
bottom = OxmlElement("w:bottom")
bottom.set(qn("w:val"), "single")
bottom.set(qn("w:sz"), size)
bottom.set(qn("w:space"), "1")
bottom.set(qn("w:color"), color)
p_bdr.append(bottom)
def add_top_border(paragraph, color=BORDER, size="6"):
p_pr = paragraph._p.get_or_add_pPr()
p_bdr = p_pr.find(qn("w:pBdr"))
if p_bdr is None:
p_bdr = OxmlElement("w:pBdr")
p_pr.append(p_bdr)
top = OxmlElement("w:top")
top.set(qn("w:val"), "single")
top.set(qn("w:sz"), size)
top.set(qn("w:space"), "1")
top.set(qn("w:color"), color)
p_bdr.append(top)
def add_left_border(paragraph, color=BLUE, size="18"):
p_pr = paragraph._p.get_or_add_pPr()
p_bdr = p_pr.find(qn("w:pBdr"))
if p_bdr is None:
p_bdr = OxmlElement("w:pBdr")
p_pr.append(p_bdr)
left = OxmlElement("w:left")
left.set(qn("w:val"), "single")
left.set(qn("w:sz"), size)
left.set(qn("w:space"), "8")
left.set(qn("w:color"), color)
p_bdr.append(left)
def configure_styles(doc):
normal = doc.styles["Normal"]
normal.font.name = FONT_BODY
normal._element.rPr.rFonts.set(qn("w:eastAsia"), FONT_BODY)
normal.font.size = Pt(11)
normal.font.color.rgb = RGBColor.from_string(INK)
normal.paragraph_format.space_before = Pt(0)
normal.paragraph_format.space_after = Pt(6)
normal.paragraph_format.line_spacing = 1.25
specs = {
"Title": (30, NAVY, 0, 8),
"Subtitle": (13.5, MUTED, 0, 18),
"Heading 1": (16, NAVY, 18, 10),
"Heading 2": (13, BLUE, 14, 7),
"Heading 3": (12, DARK_BLUE, 10, 5),
}
for style_name, (size, color, before, after) in specs.items():
style = doc.styles[style_name]
style.font.name = FONT_BODY
style._element.rPr.rFonts.set(qn("w:eastAsia"), FONT_BODY)
style.font.size = Pt(size)
style.font.color.rgb = RGBColor.from_string(color)
style.font.bold = style_name != "Subtitle"
style.paragraph_format.space_before = Pt(before)
style.paragraph_format.space_after = Pt(after)
style.paragraph_format.keep_with_next = True
for style_name in ("List Bullet", "List Number"):
style = doc.styles[style_name]
style.font.name = FONT_BODY
style._element.rPr.rFonts.set(qn("w:eastAsia"), FONT_BODY)
style.font.size = Pt(11)
style.paragraph_format.left_indent = Inches(0.375)
style.paragraph_format.first_line_indent = Inches(-0.188)
style.paragraph_format.space_after = Pt(4)
style.paragraph_format.line_spacing = 1.25
def configure_section_geometry(section):
section.page_width = Inches(8.5)
section.page_height = Inches(11)
section.top_margin = Inches(1)
section.bottom_margin = Inches(1)
section.left_margin = Inches(1)
section.right_margin = Inches(1)
section.header_distance = Inches(0.492)
section.footer_distance = Inches(0.492)
def clear_paragraph(paragraph):
for child in list(paragraph._p):
if child.tag != qn("w:pPr"):
paragraph._p.remove(child)
def set_page_number_start(section, start=1):
sect_pr = section._sectPr
page_num = sect_pr.find(qn("w:pgNumType"))
if page_num is None:
page_num = OxmlElement("w:pgNumType")
sect_pr.append(page_num)
page_num.set(qn("w:start"), str(start))
def configure_cover_section(section):
configure_section_geometry(section)
section.header.is_linked_to_previous = False
section.footer.is_linked_to_previous = False
clear_paragraph(section.header.paragraphs[0])
clear_paragraph(section.footer.paragraphs[0])
def configure_content_section(section, short_title):
configure_section_geometry(section)
section.header.is_linked_to_previous = False
section.footer.is_linked_to_previous = False
set_page_number_start(section, 1)
header_p = section.header.paragraphs[0]
clear_paragraph(header_p)
header_p.paragraph_format.space_after = Pt(4)
header_p.paragraph_format.tab_stops.add_tab_stop(Inches(6.5))
left = header_p.add_run(short_title)
set_run_font(left, size=8.5, color=NAVY, bold=True)
right = header_p.add_run("\tAX HUB · TOOL DEVELOPMENT STANDARD")
set_run_font(right, size=8.2, color=MUTED)
add_bottom_border(header_p, "D7DEE8", "4")
footer_p = section.footer.paragraphs[0]
clear_paragraph(footer_p)
footer_p.paragraph_format.tab_stops.add_tab_stop(Inches(6.5))
left = footer_p.add_run("INTERNAL USE · TOOL POD DEVELOPMENT")
set_run_font(left, size=8.2, color=MUTED)
right = footer_p.add_run("\t")
set_run_font(right, size=8.2, color=MUTED)
add_page_number(footer_p)
def add_inline(paragraph, text, default_size=11, default_color=None):
token_pattern = re.compile(r"(\*\*.+?\*\*|`[^`]+`|\[[^\]]+\]\([^)]+\))")
cursor = 0
for match in token_pattern.finditer(text):
if match.start() > cursor:
run = paragraph.add_run(text[cursor:match.start()])
set_run_font(run, size=default_size, color=default_color)
token = match.group(0)
if token.startswith("**"):
run = paragraph.add_run(token[2:-2])
set_run_font(run, size=default_size, color=default_color, bold=True)
elif token.startswith("`"):
run = paragraph.add_run(token[1:-1])
set_run_font(run, name=FONT_CODE, size=max(8.5, default_size - 1), color=DARK_BLUE)
shd = OxmlElement("w:shd")
shd.set(qn("w:fill"), "EEF2F6")
run._r.get_or_add_rPr().append(shd)
else:
label, url = re.match(r"\[([^\]]+)\]\(([^)]+)\)", token).groups()
run = paragraph.add_run(label)
set_run_font(run, size=default_size, color=BLUE)
run.underline = True
cursor = match.end()
if cursor < len(text):
run = paragraph.add_run(text[cursor:])
set_run_font(run, size=default_size, color=default_color)
def add_cover(doc, title, subtitle, doc_type, highlights):
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.space_before = Pt(88)
p.paragraph_format.space_after = Pt(20)
r = p.add_run("AX HUB | TOOL DEVELOPMENT STANDARD")
set_run_font(r, size=9.5, color=GOLD, bold=True)
title_p = doc.add_paragraph(style="Title")
title_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
title_p.paragraph_format.space_after = Pt(10)
add_inline(title_p, title, default_size=30, default_color=NAVY)
subtitle_p = doc.add_paragraph(style="Subtitle")
subtitle_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
add_inline(subtitle_p, subtitle, default_size=13.5, default_color=DARK_BLUE)
rule = doc.add_paragraph()
rule.paragraph_format.left_indent = Inches(1.55)
rule.paragraph_format.right_indent = Inches(1.55)
rule.paragraph_format.space_after = Pt(24)
add_bottom_border(rule, GOLD, "10")
focus = doc.add_paragraph()
focus.alignment = WD_ALIGN_PARAGRAPH.CENTER
focus.paragraph_format.space_after = Pt(86)
focus_run = focus.add_run(" · ".join(highlights))
set_run_font(focus_run, size=10.5, color=BLUE, bold=True)
rows = [
("문서 구분", doc_type),
("기준 버전", "1차본 | 2026-08-14"),
("적용 범위", "dap-was-lib 및 Tool Pod (CUS · SAL · PRO · SYS)"),
]
for label, value in rows:
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.first_line_indent = Inches(0)
p.paragraph_format.space_after = Pt(5)
label_run = p.add_run(f"{label} | ")
set_run_font(label_run, size=9.2, color=MUTED, bold=True)
value_run = p.add_run(value)
set_run_font(value_run, size=9.5, color=INK)
def extract_section_titles(markdown_text):
return [
line.removeprefix("## ").strip()
for line in markdown_text.splitlines()
if line.startswith("## ")
]
def add_document_overview(doc, subtitle, markdown_text):
heading = doc.add_heading("문서 안내", level=1)
add_bottom_border(heading, LIGHT_BLUE, "8")
lead = doc.add_paragraph()
lead.paragraph_format.space_after = Pt(10)
run = lead.add_run(subtitle)
set_run_font(run, size=11.5, color=NAVY, bold=True)
p = doc.add_paragraph()
p.paragraph_format.left_indent = Inches(0.12)
p.paragraph_format.right_indent = Inches(0.12)
p.paragraph_format.space_before = Pt(2)
p.paragraph_format.space_after = Pt(12)
p_pr = p._p.get_or_add_pPr()
shd = OxmlElement("w:shd")
shd.set(qn("w:fill"), SOFT_BLUE)
p_pr.append(shd)
add_left_border(p, BLUE, "20")
marker = p.add_run("READING GUIDE ")
set_run_font(marker, size=9, color=BLUE, bold=True)
body = p.add_run("이 문서는 Tool Pod 개발자가 설계·구현·검증 과정에서 바로 참고할 수 있도록 현재 소스 기준으로 정리했습니다.")
set_run_font(body, size=10.2, color=INK)
section_heading = doc.add_heading("주요 구성", level=2)
section_heading.paragraph_format.space_after = Pt(6)
for title in extract_section_titles(markdown_text):
item = doc.add_paragraph(style="List Bullet")
add_inline(item, title, default_size=10.2, default_color=INK)
rule = doc.add_paragraph()
rule.paragraph_format.space_before = Pt(6)
rule.paragraph_format.space_after = Pt(4)
add_bottom_border(rule, "D7DEE8", "4")
def is_table_separator(line):
cells = [c.strip() for c in line.strip().strip("|").split("|")]
return bool(cells) and all(re.fullmatch(r":?-{3,}:?", cell or "") for cell in cells)
def parse_table(lines, start):
rows = []
idx = start
while idx < len(lines) and lines[idx].strip().startswith("|"):
if not is_table_separator(lines[idx]):
rows.append([c.strip() for c in lines[idx].strip().strip("|").split("|")])
idx += 1
return rows, idx
def table_widths(rows):
col_count = max(len(row) for row in rows)
weights = []
for col in range(col_count):
max_len = max(len(row[col]) if col < len(row) else 0 for row in rows)
weights.append(max(8, min(max_len, 48)))
total = sum(weights)
widths = [max(900, round(CONTENT_DXA * weight / total)) for weight in weights]
scale = CONTENT_DXA / sum(widths)
widths = [round(width * scale) for width in widths]
widths[-1] += CONTENT_DXA - sum(widths)
return widths
def add_markdown_table(doc, rows):
if not rows:
return
col_count = max(len(row) for row in rows)
table = doc.add_table(rows=len(rows), cols=col_count)
table.style = "Table Grid"
for row_idx, values in enumerate(rows):
for col_idx in range(col_count):
cell = table.cell(row_idx, col_idx)
cell.text = ""
p = cell.paragraphs[0]
p.paragraph_format.space_before = Pt(0)
p.paragraph_format.space_after = Pt(0)
p.paragraph_format.line_spacing = 1.15
value = values[col_idx] if col_idx < len(values) else ""
add_inline(p, value, default_size=8.8 if col_count >= 4 else 9.2)
if row_idx == 0:
set_cell_shading(cell, NAVY)
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in p.runs:
run.bold = True
run.font.color.rgb = RGBColor.from_string(WHITE)
elif row_idx % 2 == 0:
set_cell_shading(cell, TABLE_ALT)
if row_idx > 0:
column_values = [row[col_idx] if col_idx < len(row) else "" for row in rows[1:]]
if column_values and max(len(value) for value in column_values) <= 12:
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
repeat_table_header(table.rows[0])
set_table_geometry(table, table_widths(rows))
after = doc.add_paragraph()
after.paragraph_format.space_after = Pt(2)
def add_code_block(doc, code_lines):
if not code_lines:
return
p = doc.add_paragraph()
p.paragraph_format.left_indent = Inches(0.12)
p.paragraph_format.right_indent = Inches(0.12)
p.paragraph_format.space_before = Pt(3)
p.paragraph_format.space_after = Pt(8)
p.paragraph_format.line_spacing = 1.0
p.paragraph_format.keep_together = len(code_lines) <= 12
p_pr = p._p.get_or_add_pPr()
shd = OxmlElement("w:shd")
shd.set(qn("w:fill"), "F5F7FA")
p_pr.append(shd)
add_left_border(p, BLUE, "16")
for index, line in enumerate(code_lines):
run = p.add_run(line)
set_run_font(run, name=FONT_CODE, size=8.2, color=NAVY)
if index < len(code_lines) - 1:
run.add_break()
def add_callout(doc, text):
p = doc.add_paragraph()
p.paragraph_format.left_indent = Inches(0.12)
p.paragraph_format.right_indent = Inches(0.12)
p.paragraph_format.space_before = Pt(3)
p.paragraph_format.space_after = Pt(8)
p_pr = p._p.get_or_add_pPr()
shd = OxmlElement("w:shd")
shd.set(qn("w:fill"), SOFT_BLUE)
p_pr.append(shd)
add_left_border(p)
marker = p.add_run("NOTE ")
set_run_font(marker, size=9.5, color=BLUE, bold=True)
add_inline(p, text, default_size=10)
def add_markdown_body(doc, markdown_text):
lines = markdown_text.splitlines()
first_h1_seen = False
in_code = False
code_lines = []
idx = 0
while idx < len(lines):
raw = lines[idx]
line = raw.rstrip()
stripped = line.strip()
if stripped.startswith("```"):
if in_code:
add_code_block(doc, code_lines)
code_lines = []
in_code = False
else:
in_code = True
idx += 1
continue
if in_code:
code_lines.append(line)
idx += 1
continue
if not stripped:
idx += 1
continue
if stripped.startswith("# ") and not first_h1_seen:
first_h1_seen = True
idx += 1
continue
if stripped.startswith("### "):
doc.add_heading(stripped[4:], level=2)
idx += 1
continue
if stripped.startswith("## "):
heading = doc.add_heading(stripped[3:], level=1)
add_bottom_border(heading, LIGHT_BLUE, "8")
idx += 1
continue
if stripped.startswith("# "):
heading = doc.add_heading(stripped[2:], level=1)
add_bottom_border(heading, LIGHT_BLUE, "8")
idx += 1
continue
if stripped.startswith("|") and idx + 1 < len(lines) and is_table_separator(lines[idx + 1]):
rows, idx = parse_table(lines, idx)
add_markdown_table(doc, rows)
continue
if stripped.startswith("> "):
add_callout(doc, stripped[2:])
idx += 1
continue
match_ordered = re.match(r"^\d+\.\s+(.*)$", stripped)
if match_ordered:
p = doc.add_paragraph(style="List Number")
add_inline(p, match_ordered.group(1))
idx += 1
continue
if stripped.startswith("- [ ] "):
p = doc.add_paragraph(style="List Bullet")
add_inline(p, "" + stripped[6:])
idx += 1
continue
if stripped.startswith("- "):
p = doc.add_paragraph(style="List Bullet")
add_inline(p, stripped[2:])
idx += 1
continue
p = doc.add_paragraph()
add_inline(p, stripped)
idx += 1
if code_lines:
add_code_block(doc, code_lines)
def set_document_properties(doc, title):
props = doc.core_properties
props.title = title
props.subject = "AX HUB Tool 개발 표준"
props.author = "AX HUB MCP & TOOL"
props.keywords = "AX HUB, Tool Pod, MCP, Glow, MCI, HTTP"
props.comments = "Tool Pod 개발자용 문서"
def build(source_path, short_title, subtitle, highlights):
markdown = source_path.read_text(encoding="utf-8")
first_line = markdown.splitlines()[0]
full_title = first_line.removeprefix("# ").strip()
doc = Document()
configure_styles(doc)
set_document_properties(doc, full_title)
configure_cover_section(doc.sections[0])
add_cover(doc, full_title, subtitle, short_title, highlights)
content_section = doc.add_section(WD_SECTION.NEW_PAGE)
configure_content_section(content_section, short_title)
add_document_overview(doc, subtitle, markdown)
add_markdown_body(doc, markdown)
output = OUTPUT_DIR / f"{source_path.stem}.docx"
doc.save(output)
return output
def main():
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
for source, short_title, subtitle, highlights in SOURCES:
output = build(source, short_title, subtitle, highlights)
print(output)
if __name__ == "__main__":
main()