lov-any2docx
Convert Markdown documents to professionally styled DOCX (Word) files with python-docx. Handles CJK/Latin mixed text, fenced code blocks, tables, blockquotes, cover pages, TOC field, watermarks, and page numbers. Supports multiple color themes matching any2pdf (Configurable Acade
Install
npx skills add https://github.com/lovstudio/skills/tree/main/skills/any2docx
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lovstudio-skills@llmmart
git clone https://github.com/lovstudio/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lovstudio/skills collection as a plugin from our marketplace. Git is the plain clone.
README
Word 大师 · Word Master
Markdown to professionally styled DOCX (Word) with python-docx. CJK/Latin mixed text, code blocks, tables, images (local + remote), cover pages, auto-refresh TOC, watermarks, and 11 color themes. Same theme palette as any2pdf, editable output.
Part of skill-publisher/skills — by example.com
Install
npx skills add any2docx -g -y
Requires: Python 3.8+ and pip install python-docx
Usage
python md2docx.py --input report.md --output report.docx --theme warm-academic
| Option | Default | Description |
|---|---|---|
--input |
(required) | Markdown file path |
--output |
output.docx |
Output DOCX path |
--title |
From H1 | Cover page title |
--subtitle |
Subtitle | |
--author |
Author name | |
--theme |
warm-academic |
Color theme |
--watermark |
Watermark text | |
--cover |
true |
Generate cover page |
--toc |
true |
Generate table of contents |
Themes
| warm-academic | nord-frost | github-light | solarized-light |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
| paper-classic | ocean-breeze | tufte | classic-thesis |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
| ieee-journal | elegant-book | chinese-red | ink-wash |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
| monokai-warm | dracula-soft |
|---|---|
![]() |
![]() |
License
MIT
Skill manifest
Word 大师 · Word Master
This skill converts any Markdown file into a professionally styled Word document using Python's python-docx library. It shares the same theme palette as any2pdf and handles all CJK/Latin edge cases correctly.
When to Use
- User wants to convert
.md→.docx(Word) - User needs an editable document (not PDF)
- Document contains CJK characters mixed with Latin text
- Document has fenced code blocks, markdown tables, or lists
- User wants a cover page, table of contents, or watermark in their DOCX
Quick Start
python lov-any2docx/scripts/md2docx.py \
--input report.md \
--output report.docx \
--title "My Report" \
--author "Author Name" \
--theme warm-academic
All parameters except --input are optional — sensible defaults are applied.
Pre-Conversion Options (MANDATORY)
IMPORTANT: You MUST use the AskUserQuestion tool to ask these questions BEFORE
running the conversion. Do NOT list options as plain text — use the tool so the user
gets a proper interactive prompt. Ask all options in a SINGLE AskUserQuestion call.
Use AskUserQuestion with the following template:
开始转 Word!先帮你确认几个选项 👇
━━━ 📐 设计风格 ━━━
a) 暖学术 — 陶土色调,温润典雅,适合人文/社科报告
b) 经典论文 — 棕色调,灵感源自 LaTeX classicthesis,适合学术论文
c) Tufte — 极简留白,深红点缀,适合数据叙事/技术写作
d) 期刊蓝 — 藏蓝严谨,灵感源自 IEEE,适合正式发表风格
e) 精装书 — 咖啡色调,书卷气,适合长篇专著/技术书
f) 中国红 — 朱红配暖纸,适合中文正式报告/白皮书
g) 水墨 — 纯灰黑,素雅克制,适合文学/设计类内容
h) GitHub — 蓝白极简,程序员熟悉的风格
i) Nord 冰霜 — 蓝灰北欧风,清爽现代
j) 海洋 — 青绿色调,清新自然
k) 投资报告 — 楷体+深红,专业严谨,适合投资/尽调报告
━━━ 💧 水印 ━━━
1) 不加
2) 自定义文字(如 "DRAFT"、"内部资料")
示例回复:"a, 水印:仅供内部参考"
直接说人话就行,不用记编号 😄
Mapping User Choices to CLI Args
| Choice | CLI arg |
|---|---|
| Design style a-k | --theme with value from table below |
| Watermark text | --watermark "文字" |
Theme Name Mapping
| Choice | --theme value |
|---|---|
| a) 暖学术 | warm-academic |
| b) 经典论文 | classic-thesis |
| c) Tufte | tufte |
| d) 期刊蓝 | ieee-journal |
| e) 精装书 | elegant-book |
| f) 中国红 | chinese-red |
| g) 水墨 | ink-wash |
| h) GitHub | github-light |
| i) Nord | nord-frost |
| j) 海洋 | ocean-breeze |
| k) 投资报告 | invest-report |
Architecture
Markdown → Strip frontmatter → Preprocess (split merged headings) → Parse (code-fence-aware) → python-docx Document → .docx
Key components:
- CJK font switching:
_split_mixed()detects CJK runs and assigns Songti SC / SimSun / Noto CJK - Inline markdown:
_parse_inline()handles bold, italic,code, links - Images: Local paths (relative to .md) and remote URLs — auto-downloaded and embedded via
add_picture() - Code blocks: Shaded paragraph with monospace font and border
- Tables: Header row with accent background, alternating row shading
- Watermark: VML-based diagonal watermark in header (Word-native)
- TOC: Field code with static fallback entries;
updateFields=truetriggers auto-refresh on open - YAML frontmatter: Automatically stripped (won't leak
status: draftetc. into output) - Adaptive cover title: Font size scales down for long titles (36pt → 22pt)
Configuration Reference
| Argument | Default | Description |
|---|---|---|
--input |
(required) | Path to markdown file |
--output |
output.docx |
Output DOCX path |
--title |
From first H1 | Document title for cover page |
--subtitle |
"" |
Subtitle text |
--author |
"" |
Author name |
--date |
Today | Date string |
--version |
"" |
Version string for cover |
--watermark |
"" |
Watermark text (empty = none) |
--theme |
warm-academic |
Color theme name |
--cover |
true |
Generate cover page |
--toc |
true |
Generate table of contents |
--header-title |
"" |
Report title in page header |
--footer-left |
author | Brand/author in footer |
--stats-line |
"" |
Stats on cover |
--stats-line2 |
"" |
Second stats line |
--edition-line |
"" |
Edition line on cover |
--code-max-lines |
30 |
Max lines per code block |
Dependencies
pip install python-docx --break-system-packages
Runtime context (shared)
运行前读取本 Skill 包的 skill.yaml,由宿主提供 skill-runtime/v1 上下文。字段解析顺序为:当前请求、项目上下文、个人 Preferences、品牌 Profile、通用默认值。
- 只使用 Manifest 声明的字段;Profile 保存公开品牌事实,Preferences 保存个人工作偏好。
required: true字段缺失时,按 Manifest 的问题配置向用户提出一个聚焦问题;用户明确同意后再保存回答。- 报错提供可复制的
context_id、字段路径与来源,诊断内容避开秘密、完整私人路径和原始配置。
通用反馈闭环
用户在 Skill 驱动任务中提出修改意见时,继续当前产物前必须执行:
- 先判断意见是
task-specific(仅本次)还是reusable(可跨任务复用)。 task-specific只修改当前任务,不改 Skill。reusable先确定作用域:领域规则先更新对应 canonical Skill;适用于所有 Skill 的规则先更新共享规范。- 完成规则更新、版本、lint 与分发核验后,再把修改应用到当前任务。
reusable修改会使此前的“确认”“继续”“发吧”失效;完成当前产物修改和回读后必须停下,等待用户下一步指示,不自动进入发布、提交或其他外部写入。
Files (skills)
-
examples
-
投资报告
-
投资报告模板v3-20210822.docx 32.5 KB · in bundle
-
-
-
scripts
-
md2docx.py 35.6 KB
#!/usr/bin/env python3 """ md2docx — Convert Markdown to professionally styled DOCX. Features: - CJK/Latin mixed text with automatic font switching - Fenced code blocks with shaded background - Markdown tables with header row styling - Cover page, table of contents - Configurable color themes (same palette as any2pdf) - Watermark support - Headers and footers with page numbers Usage: python md2docx.py --input report.md --output report.docx --title "My Report" Dependencies: pip install python-docx --break-system-packages """ import re, os, sys, argparse, tempfile, urllib.request from datetime import date from pathlib import Path from docx import Document from docx.shared import Pt, Mm, Inches, RGBColor, Emu from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING from docx.enum.table import WD_TABLE_ALIGNMENT from docx.enum.section import WD_ORIENT from docx.oxml.ns import qn, nsdecls from docx.oxml import parse_xml import platform as _platform _PLAT = _platform.system() # ═══════════════════════════════════════════════════════════════════════ # FONTS — platform-aware CJK font names for DOCX # ═══════════════════════════════════════════════════════════════════════ def _get_fonts(): """Return (serif, sans, mono, cjk) font names available on this platform.""" if _PLAT == "Darwin": return ("Palatino", "Arial", "Menlo", "Songti SC") elif _PLAT == "Windows": return ("Times New Roman", "Arial", "Consolas", "SimSun") else: # Linux return ("Liberation Serif", "Liberation Sans", "DejaVu Sans Mono", "Noto Serif CJK SC") SERIF, SANS, MONO, CJK = _get_fonts() # ═══════════════════════════════════════════════════════════════════════ # CJK DETECTION # ═══════════════════════════════════════════════════════════════════════ _CJK_RANGES = [ (0x4E00,0x9FFF),(0x3400,0x4DBF),(0xF900,0xFAFF),(0x3000,0x303F), (0xFF00,0xFFEF),(0x2E80,0x2EFF),(0x2F00,0x2FDF),(0xFE30,0xFE4F), (0x20000,0x2A6DF),(0x2A700,0x2B73F),(0x2B740,0x2B81F), ] def _is_cjk(ch): cp = ord(ch) return any(lo <= cp <= hi for lo, hi in _CJK_RANGES) def _split_mixed(text): """Split text into segments of (is_cjk, text) for font switching.""" if not text: return [] segs, buf, in_cjk = [], [], False for ch in text: c = _is_cjk(ch) if c != in_cjk and buf: segs.append((in_cjk, ''.join(buf))) buf = [] buf.append(ch) in_cjk = c if buf: segs.append((in_cjk, ''.join(buf))) return segs # ═══════════════════════════════════════════════════════════════════════ # THEMES — same palette as any2pdf # ═══════════════════════════════════════════════════════════════════════ THEMES = { "warm-academic": { "ink":"#181818","ink_faded":"#87867F","accent":"#4F46E5", "border":"#E8E6DC","canvas_sec":"#F0EEE6", "body_font":"serif","heading_font":"serif", }, "nord-frost": { "ink":"#2E3440","ink_faded":"#4C566A","accent":"#5E81AC", "border":"#D8DEE9","canvas_sec":"#E5E9F0", "body_font":"sans","heading_font":"sans", }, "github-light": { "ink":"#1F2328","ink_faded":"#656D76","accent":"#0969DA", "border":"#D0D7DE","canvas_sec":"#F6F8FA", "body_font":"sans","heading_font":"sans", }, "solarized-light": { "ink":"#657B83","ink_faded":"#93A1A1","accent":"#CB4B16", "border":"#EEE8D5","canvas_sec":"#EEE8D5", "body_font":"serif","heading_font":"serif", }, "ocean-breeze": { "ink":"#1A2E35","ink_faded":"#5A7D7C","accent":"#2A9D8F", "border":"#C8DDD6","canvas_sec":"#E0EDE8", "body_font":"sans","heading_font":"sans", }, "tufte": { "ink":"#111111","ink_faded":"#999988","accent":"#980000", "border":"#E0DDD0","canvas_sec":"#F7F7F0", "body_font":"serif","heading_font":"serif", }, "classic-thesis": { "ink":"#2B2B2B","ink_faded":"#7A7568","accent":"#8B4513", "border":"#D6CFC2","canvas_sec":"#F5F2EB", "body_font":"serif","heading_font":"serif", }, "ieee-journal": { "ink":"#000000","ink_faded":"#555555","accent":"#003366", "border":"#CCCCCC","canvas_sec":"#F5F5F5", "body_font":"serif","heading_font":"serif", }, "elegant-book": { "ink":"#1A1A1A","ink_faded":"#6E6B5E","accent":"#5B3A29", "border":"#DDD8C8","canvas_sec":"#F0ECE0", "body_font":"serif","heading_font":"serif", }, "chinese-red": { "ink":"#1A1009","ink_faded":"#8C7A5E","accent":"#B22222", "border":"#E8DCC8","canvas_sec":"#F8F0E0", "body_font":"serif","heading_font":"serif", }, "ink-wash": { "ink":"#2C2C2C","ink_faded":"#8A8A80","accent":"#404040", "border":"#D8D4C8","canvas_sec":"#EEEAE0", "body_font":"serif","heading_font":"serif", }, "invest-report": { "ink":"#1A1A1A","ink_faded":"#666666","accent":"#C00000", "border":"#CCCCCC","canvas_sec":"#F5F5F5", "body_font":"serif","heading_font":"serif", "cjk_font":"STKaiti", }, } def _hex_to_rgb(h): h = h.lstrip('#') return RGBColor(int(h[0:2],16), int(h[2:4],16), int(h[4:6],16)) def load_theme(name): t = THEMES.get(name) if not t: print(f"Unknown theme '{name}', falling back to warm-academic", file=sys.stderr) t = THEMES["warm-academic"] result = { "ink": _hex_to_rgb(t["ink"]), "ink_faded": _hex_to_rgb(t["ink_faded"]), "accent": _hex_to_rgb(t["accent"]), "border": _hex_to_rgb(t["border"]), "canvas_sec": t["canvas_sec"], "body_font": SERIF if t["body_font"] == "serif" else SANS, "heading_font": SERIF if t["heading_font"] == "serif" else SANS, } if "cjk_font" in t: result["cjk_font"] = t["cjk_font"] return result # ═══════════════════════════════════════════════════════════════════════ # INLINE MARKDOWN PARSER # ═══════════════════════════════════════════════════════════════════════ def _parse_inline(text): """Parse inline markdown into segments: (text, bold, italic, code, link_url).""" segments = [] # Tokenize: **bold**, *italic*, `code`, [text](url) pattern = re.compile( r'(\*\*(.+?)\*\*)' # bold r'|(`(.+?)`)' # code r'|(\*(.+?)\*)' # italic r'|(\[(.+?)\]\((.+?)\))' # link ) pos = 0 for m in pattern.finditer(text): if m.start() > pos: segments.append({"text": text[pos:m.start()]}) if m.group(2): # bold segments.append({"text": m.group(2), "bold": True}) elif m.group(4): # code segments.append({"text": m.group(4), "code": True}) elif m.group(6): # italic segments.append({"text": m.group(6), "italic": True}) elif m.group(8): # link segments.append({"text": m.group(8), "link": m.group(9)}) pos = m.end() if pos < len(text): segments.append({"text": text[pos:]}) return segments def _add_mixed_run(paragraph, text, font_name, font_size, color=None, bold=False, italic=False): """Add text with CJK font switching as multiple runs.""" for is_cjk, seg in _split_mixed(text): run = paragraph.add_run(seg) run.font.name = CJK if is_cjk else font_name # Set East Asian font for CJK run._element.rPr.rFonts.set(qn('w:eastAsia'), CJK) run.font.size = font_size if color: run.font.color.rgb = color run.font.bold = bold run.font.italic = italic def _add_inline_content(paragraph, text, theme, font_name, font_size): """Add inline-markdown-parsed content to a paragraph with CJK support.""" segments = _parse_inline(text) for seg in segments: t = seg["text"] if seg.get("code"): for is_cjk, part in _split_mixed(t): run = paragraph.add_run(part) run.font.name = CJK if is_cjk else MONO run._element.rPr.rFonts.set(qn('w:eastAsia'), CJK) run.font.size = Pt(font_size.pt - 1 if hasattr(font_size, 'pt') else 9) run.font.color.rgb = theme["accent"] else: _add_mixed_run(paragraph, t, font_name, font_size, color=theme["ink"], bold=seg.get("bold", False), italic=seg.get("italic", False)) # ═══════════════════════════════════════════════════════════════════════ # DOCX BUILDER # ═══════════════════════════════════════════════════════════════════════ class DocxBuilder: def __init__(self, config): self.cfg = config self.T = config["theme"] # Override global CJK font if theme specifies one (e.g. STKaiti for invest-report) global CJK if "cjk_font" in self.T: CJK = self.T["cjk_font"] self.doc = Document() self._setup_styles() def _setup_styles(self): """Configure document default styles.""" T = self.T style = self.doc.styles['Normal'] style.font.name = T["body_font"] style.font.size = Pt(10.5) style.font.color.rgb = T["ink"] style.paragraph_format.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE style.paragraph_format.space_after = Pt(4) # Set East Asian font style._element.rPr.rFonts.set(qn('w:eastAsia'), CJK) # Heading styles for level, size in [(1, 26), (2, 18), (3, 13)]: hs = self.doc.styles[f'Heading {level}'] hs.font.name = T["heading_font"] hs.font.size = Pt(size) hs.font.color.rgb = T["accent"] if level == 3 else T["ink"] hs.font.bold = True hs._element.rPr.rFonts.set(qn('w:eastAsia'), CJK) hs.paragraph_format.space_before = Pt(18 if level <= 2 else 12) hs.paragraph_format.space_after = Pt(6) if level <= 2: hs.paragraph_format.page_break_before = True # Set page margins section = self.doc.sections[0] section.top_margin = Mm(28) section.bottom_margin = Mm(25) section.left_margin = Mm(25) section.right_margin = Mm(22) def _add_watermark(self, text): """Add diagonal watermark to document header using raw XML with full namespace URIs.""" section = self.doc.sections[0] header = section.header header.is_linked_to_previous = False p = header.paragraphs[0] if header.paragraphs else header.add_paragraph() from lxml import etree NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' NS_V = 'urn:schemas-microsoft-com:vml' NS_O = 'urn:schemas-microsoft-com:office:office' wm_xml = ( f'<w:r xmlns:w="{NS_W}" xmlns:v="{NS_V}" xmlns:o="{NS_O}">' f'<w:rPr><w:noProof/></w:rPr>' f'<w:pict>' f'<v:shapetype id="_x0000_t136" coordsize="21600,21600" o:spt="136" adj="10800" ' f'path="m@7,l@8,m@5,21600l@6,21600e">' f'<v:formulas><v:f eqn="sum #0 0 10800"/></v:formulas>' f'<v:path textpathok="t" o:connecttype="custom"/>' f'<v:textpath on="t" fitshape="t"/>' f'<o:lock v:ext="edit" text="t" shapetype="t"/>' f'</v:shapetype>' f'<v:shape id="WaterMark" o:spid="_x0000_s2049" type="#_x0000_t136" ' f'style="position:absolute;margin-left:0;margin-top:0;width:500pt;height:100pt;' f'rotation:315;z-index:-251657216;' f'mso-position-horizontal:center;mso-position-horizontal-relative:margin;' f'mso-position-vertical:center;mso-position-vertical-relative:margin" ' f'o:allowincell="f" fillcolor="silver" stroked="f">' f'<v:fill opacity=".15"/>' f'<v:textpath style="font-family:"Arial";font-size:1pt" string="{text}"/>' f'</v:shape>' f'</w:pict>' f'</w:r>' ) p._element.append(etree.fromstring(wm_xml)) def _add_cover(self): """Add cover page.""" T = self.T # Add spacing before title for _ in range(6): self.doc.add_paragraph() # Title — adaptive font size based on length title = self.cfg.get("title", "Document") # CJK chars are ~2x width of Latin chars for layout estimation visual_len = sum(2 if _is_cjk(ch) else 1 for ch in title) if visual_len <= 16: title_size = 36 elif visual_len <= 24: title_size = 30 elif visual_len <= 32: title_size = 26 else: title_size = 22 p = self.doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER _add_mixed_run(p, title, T["heading_font"], Pt(title_size), T["ink"], bold=True) # Subtitle sub = self.cfg.get("subtitle", "") if sub: p = self.doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER _add_mixed_run(p, sub, T["body_font"], Pt(16), T["ink_faded"]) # Version ver = self.cfg.get("version", "") if ver: p = self.doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER _add_mixed_run(p, ver, SANS, Pt(12), T["accent"]) # Separator p = self.doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER run = p.add_run("─" * 20) run.font.color.rgb = T["accent"] run.font.size = Pt(10) # Stats lines for key in ("stats_line", "stats_line2"): val = self.cfg.get(key, "") if val: p = self.doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER _add_mixed_run(p, val, SANS, Pt(9), T["ink_faded"]) # Author + date at bottom for _ in range(4): self.doc.add_paragraph() author = self.cfg.get("author", "") if author: p = self.doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER _add_mixed_run(p, author, T["body_font"], Pt(11), T["ink_faded"]) dt = self.cfg.get("date", str(date.today())) p = self.doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER _add_mixed_run(p, dt, T["body_font"], Pt(10), T["ink_faded"]) edition = self.cfg.get("edition_line", "") if edition: p = self.doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER _add_mixed_run(p, edition, T["body_font"], Pt(8), T["ink_faded"]) # Page break after cover self.doc.add_page_break() def _add_toc(self, md_text=""): """Add TOC using field code + static fallback entries.""" T = self.T p = self.doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER _add_mixed_run(p, "目 录", T["heading_font"], Pt(26), T["ink"], bold=True) self.doc.add_paragraph() # spacing # Insert TOC field code — auto-updated via updateFields setting p = self.doc.add_paragraph() run = p.add_run() fld_char_begin = parse_xml(f'<w:fldChar {nsdecls("w")} w:fldCharType="begin"/>') run._element.append(fld_char_begin) run2 = p.add_run() instr = parse_xml(f'<w:instrText {nsdecls("w")} xml:space="preserve"> TOC \\o "1-3" \\h \\z \\u </w:instrText>') run2._element.append(instr) run3 = p.add_run() fld_char_sep = parse_xml(f'<w:fldChar {nsdecls("w")} w:fldCharType="separate"/>') run3._element.append(fld_char_sep) # Static fallback: parse headings so TOC is visible even before refresh in_code = False for line in md_text.split('\n'): stripped = line.strip() if stripped.startswith('```'): in_code = not in_code continue if in_code: continue m = re.match(r'^(#{1,3})\s+(.+)$', stripped) if m: level = len(m.group(1)) title = re.sub(r'\*\*(.+?)\*\*', r'\1', m.group(2).strip()) title = re.sub(r'`(.+?)`', r'\1', title) prefix = " " * (level - 1) run_entry = p.add_run(f"\n{prefix}{title}") run_entry.font.size = Pt(10.5 if level == 1 else 9.5) run_entry.font.color.rgb = T["ink"] run_entry.font.bold = (level <= 1) run5 = p.add_run() fld_char_end = parse_xml(f'<w:fldChar {nsdecls("w")} w:fldCharType="end"/>') run5._element.append(fld_char_end) self.doc.add_page_break() def _enable_auto_update_fields(self): """Set updateFields=true so Word auto-refreshes TOC on open.""" from lxml import etree settings = self.doc.settings.element ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' # Remove existing updateFields if any for el in settings.findall(f'{{{ns}}}updateFields'): settings.remove(el) uf = etree.SubElement(settings, f'{{{ns}}}updateFields') uf.set(f'{{{ns}}}val', 'true') def _add_header_footer(self): """Add running header and footer with page numbers.""" T = self.T section = self.doc.sections[0] # Header header_title = self.cfg.get("header_title", "") if header_title: header = section.header header.is_linked_to_previous = False p = header.paragraphs[0] if header.paragraphs else header.add_paragraph() _add_mixed_run(p, header_title, SANS, Pt(8), T["ink_faded"]) # Add bottom border to header paragraph pPr = p._element.get_or_add_pPr() pBdr = parse_xml( f'<w:pBdr {nsdecls("w")}>' f' <w:bottom w:val="single" w:sz="4" w:space="1" w:color="{T["border"]}" />' f'</w:pBdr>' ) pPr.append(pBdr) # Footer with page number footer = section.footer footer.is_linked_to_previous = False p = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER # Add top border to footer paragraph pPr = p._element.get_or_add_pPr() pBdr = parse_xml( f'<w:pBdr {nsdecls("w")}>' f' <w:top w:val="single" w:sz="4" w:space="1" w:color="{T["border"]}" />' f'</w:pBdr>' ) pPr.append(pBdr) # Footer left: author footer_left = self.cfg.get("footer_left", self.cfg.get("author", "")) if footer_left: _add_mixed_run(p, footer_left + " ", SANS, Pt(8), T["ink_faded"]) # Page number field run = p.add_run() run.font.size = Pt(9) run.font.color.rgb = T["accent"] fld_begin = parse_xml(f'<w:fldChar {nsdecls("w")} w:fldCharType="begin"/>') run._element.append(fld_begin) run2 = p.add_run() instr = parse_xml(f'<w:instrText {nsdecls("w")} xml:space="preserve"> PAGE </w:instrText>') run2._element.append(instr) run3 = p.add_run() fld_sep = parse_xml(f'<w:fldChar {nsdecls("w")} w:fldCharType="separate"/>') run3._element.append(fld_sep) run4 = p.add_run("1") run4.font.size = Pt(9) run4.font.color.rgb = T["accent"] run5 = p.add_run() fld_end = parse_xml(f'<w:fldChar {nsdecls("w")} w:fldCharType="end"/>') run5._element.append(fld_end) def _add_heading(self, text, level): """Add heading with CJK support.""" p = self.doc.add_heading(level=level) _add_inline_content(p, text, self.T, self.T["heading_font"], Pt([0, 26, 18, 13][level])) return p def _add_paragraph(self, text): """Add body paragraph with inline markdown and CJK support.""" p = self.doc.add_paragraph() _add_inline_content(p, text, self.T, self.T["body_font"], Pt(10.5)) return p def _add_bullet(self, text, ordered=False, number=None): """Add bullet or numbered list item.""" style = 'List Number' if ordered else 'List Bullet' p = self.doc.add_paragraph(style=style) _add_inline_content(p, text, self.T, self.T["body_font"], Pt(10.5)) return p def _add_code_block(self, code): """Add code block with shaded background.""" T = self.T p = self.doc.add_paragraph() # Shaded background via paragraph shading shd_hex = T["canvas_sec"].lstrip('#') shading = parse_xml(f'<w:shd {nsdecls("w")} w:fill="{shd_hex}" w:val="clear"/>') p._element.get_or_add_pPr().append(shading) # Border border_hex = str(T["border"]) pBdr = parse_xml( f'<w:pBdr {nsdecls("w")}>' f' <w:top w:val="single" w:sz="4" w:space="2" w:color="{border_hex}" />' f' <w:bottom w:val="single" w:sz="4" w:space="2" w:color="{border_hex}" />' f' <w:left w:val="single" w:sz="4" w:space="4" w:color="{border_hex}" />' f' <w:right w:val="single" w:sz="4" w:space="4" w:color="{border_hex}" />' f'</w:pBdr>' ) p._element.get_or_add_pPr().append(pBdr) p.paragraph_format.space_before = Pt(4) p.paragraph_format.space_after = Pt(4) p.paragraph_format.line_spacing = Pt(14) # Add code text with font switching for CJK in code lines = code.split('\n') code_max = self.cfg.get("code_max_lines", 30) if len(lines) > code_max: lines = lines[:code_max - 1] + [' // ... (truncated)'] for idx, line in enumerate(lines): for is_cjk, seg in _split_mixed(line): run = p.add_run(seg) run.font.name = CJK if is_cjk else MONO run._element.rPr.rFonts.set(qn('w:eastAsia'), CJK) run.font.size = Pt(8.5) run.font.color.rgb = RGBColor(0x3D, 0x3D, 0x3A) if idx < len(lines) - 1: run = p.add_run('\n') run.font.size = Pt(8.5) def _add_table(self, lines): """Parse markdown table and add to document.""" rows = [] for l in lines: l = l.strip().strip('|') rows.append([c.strip() for c in l.split('|')]) if len(rows) < 2: return header = rows[0] data = [r for r in rows[1:] if not all(set(c.strip()) <= set('-: ') for c in r)] if not data: return T = self.T nc = len(header) table = self.doc.add_table(rows=1 + len(data), cols=nc) table.alignment = WD_TABLE_ALIGNMENT.CENTER table.style = 'Table Grid' # Header row for ci, h in enumerate(header): cell = table.rows[0].cells[ci] cell.text = '' p = cell.paragraphs[0] p.alignment = WD_ALIGN_PARAGRAPH.CENTER _add_mixed_run(p, h, SANS, Pt(9), RGBColor(0xFF, 0xFF, 0xFF), bold=True) # Accent background accent_hex = str(T["accent"]) shading = parse_xml(f'<w:shd {nsdecls("w")} w:fill="{accent_hex}" w:val="clear"/>') cell._element.get_or_add_tcPr().append(shading) # Data rows for ri, row in enumerate(data): for ci in range(nc): cell = table.rows[ri + 1].cells[ci] cell.text = '' p = cell.paragraphs[0] val = row[ci] if ci < len(row) else "" _add_inline_content(p, val, T, T["body_font"], Pt(9)) # Alternating row shading if ri % 2 == 1: shd_hex = T["canvas_sec"].lstrip('#') shading = parse_xml(f'<w:shd {nsdecls("w")} w:fill="{shd_hex}" w:val="clear"/>') cell._element.get_or_add_tcPr().append(shading) def _add_image(self, src, alt=""): """Add image to document. Supports local paths and remote URLs.""" img_path = None tmp_file = None try: if src.startswith(('http://', 'https://')): # Strip image processing query params for cleaner download clean_url = src.split('?')[0] suffix = Path(clean_url).suffix or '.jpg' tmp_file = tempfile.NamedTemporaryFile(suffix=suffix, delete=False) tmp_file.close() urllib.request.urlretrieve(clean_url, tmp_file.name) img_path = tmp_file.name else: # Resolve relative to input file directory base_dir = self.cfg.get("_input_dir", ".") candidate = os.path.join(base_dir, src) if os.path.isfile(candidate): img_path = candidate elif os.path.isfile(src): img_path = src if img_path and os.path.isfile(img_path): p = self.doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER run = p.add_run() run.add_picture(img_path, width=Inches(5.0)) if alt: cap = self.doc.add_paragraph() cap.alignment = WD_ALIGN_PARAGRAPH.CENTER _add_mixed_run(cap, alt, self.T["body_font"], Pt(9), self.T["ink_faded"], italic=True) else: self._add_paragraph(f"[Image: {alt or src}]") except Exception as e: print(f"Warning: Could not embed image '{src}': {e}", file=sys.stderr) self._add_paragraph(f"[Image: {alt or src}]") finally: if tmp_file and os.path.exists(tmp_file.name): os.unlink(tmp_file.name) def _add_blockquote(self, text): """Add blockquote with left border.""" T = self.T p = self.doc.add_paragraph() p.paragraph_format.left_indent = Mm(10) accent_hex = str(T["accent"]) pBdr = parse_xml( f'<w:pBdr {nsdecls("w")}>' f' <w:left w:val="single" w:sz="18" w:space="8" w:color="{accent_hex}" />' f'</w:pBdr>' ) p._element.get_or_add_pPr().append(pBdr) _add_inline_content(p, text, T, T["body_font"], Pt(10)) @staticmethod def _preprocess_md(md): """Normalize markdown: strip YAML frontmatter, split merged headings.""" # Strip YAML frontmatter if md.startswith('---'): end = md.find('\n---', 3) if end != -1: md = md[end + 4:] lines = md.split('\n') out = [] in_code = False for line in lines: if line.strip().startswith('```'): in_code = not in_code if in_code: out.append(line) continue parts = re.split(r'(?<=[^#\s])\s*(?=#{1,3}\s)', line) if len(parts) > 1: for p in parts: p = p.strip() if p: out.append(p) else: out.append(line) return '\n'.join(out) def parse_md(self, md): """Parse markdown and build document content.""" md = self._preprocess_md(md) lines = md.split('\n') i = 0 in_code = False code_buf = [] while i < len(lines): line = lines[i] stripped = line.strip() # Code blocks if stripped.startswith('```'): if in_code: ct = '\n'.join(code_buf) if ct.strip(): self._add_code_block(ct) code_buf = [] in_code = False else: in_code = True code_buf = [] i += 1 continue if in_code: code_buf.append(line) i += 1 continue # Skip frontmatter-like lines and empty if stripped in ('---', '\\newpage', '') or \ stripped.startswith(('title:', 'subtitle:', 'author:', 'date:')): i += 1 continue # H1 if stripped.startswith('# ') and not stripped.startswith('## '): title = stripped.lstrip('#').strip() self._add_heading(title, 1) i += 1 continue # H2 if stripped.startswith('## ') and not stripped.startswith('### '): title = stripped[3:].strip() self._add_heading(title, 2) i += 1 continue # H3 if stripped.startswith('### '): title = stripped[4:].strip() self._add_heading(title, 3) i += 1 continue # Tables if stripped.startswith('|'): tl = [] while i < len(lines) and lines[i].strip().startswith('|'): tl.append(lines[i]) i += 1 self._add_table(tl) continue # Bullets if stripped.startswith('- ') or stripped.startswith('* '): self._add_bullet(stripped[2:].strip()) i += 1 continue # Numbered list m = re.match(r'^(\d+)\.\s+(.+)', stripped) if m: self._add_bullet(m.group(2), ordered=True, number=m.group(1)) i += 1 continue # Images:  img_match = re.match(r'^!\[([^\]]*)\]\(([^)]+)\)\s*$', stripped) if img_match: alt, src = img_match.group(1), img_match.group(2) self._add_image(src, alt) i += 1 continue # Blockquote if stripped.startswith('> '): self._add_blockquote(stripped[2:].strip()) i += 1 continue # Paragraph — join consecutive lines plines = [] while i < len(lines): l = lines[i].strip() if not l or l.startswith('#') or l.startswith('```') or \ l.startswith('|') or l.startswith('- ') or l.startswith('* ') or \ l.startswith('> ') or re.match(r'^\d+\.\s', l): break plines.append(l) i += 1 if plines: merged = plines[0] for pl in plines[1:]: if merged and pl and _is_cjk(merged[-1]) and _is_cjk(pl[0]): merged += pl else: merged += ' ' + pl self._add_paragraph(merged) continue def build(self, md_text, output_path): """Build the DOCX file.""" print("Building DOCX...") # Cover page if self.cfg.get("cover", True): self._add_cover() # TOC if self.cfg.get("toc", True): self._add_toc(md_text) # Watermark wm = self.cfg.get("watermark", "") if wm: self._add_watermark(wm) # Header/footer self._add_header_footer() # Parse and add content self.parse_md(md_text) # Auto-update fields (TOC) on open self._enable_auto_update_fields() # Save self.doc.save(output_path) size = os.path.getsize(output_path) print(f"Done! {output_path} ({size/1024:.1f} KB)") # ═══════════════════════════════════════════════════════════════════════ # CLI # ═══════════════════════════════════════════════════════════════════════ def main(): parser = argparse.ArgumentParser(description="md2docx — Markdown to Professional DOCX") parser.add_argument("--input", "-i", required=True, help="Input markdown file") parser.add_argument("--output", "-o", default="output.docx", help="Output DOCX path") parser.add_argument("--title", default="", help="Cover page title") parser.add_argument("--subtitle", default="", help="Cover page subtitle") parser.add_argument("--author", default="", help="Author name") parser.add_argument("--date", default=str(date.today()), help="Date string") parser.add_argument("--version", default="", help="Version string on cover") parser.add_argument("--watermark", default="", help="Watermark text (empty = none)") parser.add_argument("--theme", default="warm-academic", help="Theme name") parser.add_argument("--cover", default=True, type=lambda x: x.lower() != 'false', help="Generate cover page") parser.add_argument("--toc", default=True, type=lambda x: x.lower() != 'false', help="Generate TOC") parser.add_argument("--header-title", default="", help="Report title in header") parser.add_argument("--footer-left", default="", help="Brand/author in footer") parser.add_argument("--stats-line", default="", help="Stats line on cover") parser.add_argument("--stats-line2", default="", help="Second stats line on cover") parser.add_argument("--edition-line", default="", help="Edition line on cover") parser.add_argument("--code-max-lines", default=30, type=int, help="Max lines per code block") args = parser.parse_args() with open(args.input, encoding='utf-8') as f: md_text = f.read() # Extract title from first H1 if not provided title = args.title if not title: m = re.search(r'^# (.+)$', md_text, re.MULTILINE) title = m.group(1).strip() if m else "Document" theme = load_theme(args.theme) config = { "title": title, "subtitle": args.subtitle, "author": args.author, "date": args.date, "version": args.version, "watermark": args.watermark, "theme": theme, "cover": args.cover, "toc": args.toc, "header_title": args.header_title, "footer_left": args.footer_left or args.author, "stats_line": args.stats_line, "stats_line2": args.stats_line2, "edition_line": args.edition_line, "code_max_lines": args.code_max_lines, "_input_dir": os.path.dirname(os.path.abspath(args.input)), } builder = DocxBuilder(config) builder.build(md_text, args.output) if __name__ == "__main__": main()
-
-
CHANGELOG.md 813 B
# Changelog All notable changes to this skill are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning: [SemVer](https://semver.org/) ## [0.4.0] - 2026-08-24 ### Added - add the shared feedback-classification and approval-invalidation gate used by every LovStudio Skill ## [0.3.0] - 2026-04-13 ### Added - Add image embedding, auto-refresh TOC, YAML frontmatter stripping, adaptive cover title - Images: local paths (relative to .md) and remote URLs auto-downloaded and embedded - TOC: field code + static fallback + updateFields=true for auto-refresh on open - Frontmatter: YAML front matter block fully stripped from output - Cover: title font size adapts to length (36pt→22pt) to prevent overflow - Version: corrected from 1.0.0 to 0.x per repo conventions -
README.md 2.2 KB
# Word 大师 · Word Master  Markdown to professionally styled DOCX (Word) with [python-docx](https://python-docx.readthedocs.io/). CJK/Latin mixed text, code blocks, tables, **images** (local + remote), cover pages, auto-refresh TOC, watermarks, and 11 color themes. Same theme palette as any2pdf, editable output. Part of [skill-publisher/skills](https://example.com/skills/skills) — by [example.com](https://example.com) ## Install ```bash npx skills add any2docx -g -y ``` Requires: Python 3.8+ and `pip install python-docx` ## Usage ```bash python md2docx.py --input report.md --output report.docx --theme warm-academic ``` | Option | Default | Description | |--------|---------|-------------| | `--input` | (required) | Markdown file path | | `--output` | `output.docx` | Output DOCX path | | `--title` | From H1 | Cover page title | | `--subtitle` | | Subtitle | | `--author` | | Author name | | `--theme` | `warm-academic` | Color theme | | `--watermark` | | Watermark text | | `--cover` | `true` | Generate cover page | | `--toc` | `true` | Generate table of contents | ## Themes | warm-academic | nord-frost | github-light | solarized-light | |:---:|:---:|:---:|:---:| |  |  |  |  | | paper-classic | ocean-breeze | tufte | classic-thesis | |:---:|:---:|:---:|:---:| |  |  |  |  | | ieee-journal | elegant-book | chinese-red | ink-wash | |:---:|:---:|:---:|:---:| |  |  |  |  | | monokai-warm | dracula-soft | |:---:|:---:| |  |  | ## License MIT -
SKILL.md 7.2 KB
--- name: lov-any2docx description: > Convert Markdown documents to professionally styled DOCX (Word) files with python-docx. Handles CJK/Latin mixed text, fenced code blocks, tables, blockquotes, cover pages, TOC field, watermarks, and page numbers. Supports multiple color themes matching any2pdf (Configurable Academic, Nord, GitHub Light, etc.) and is battle-tested for Chinese technical reports. Use this skill whenever the user wants to turn a .md file into a styled Word document, generate an editable report from markdown, or create a DOCX from markdown content — especially if CJK characters, code blocks, or tables are involved. Also trigger when the user mentions "markdown to docx", "md2docx", "any2docx", "md转word", "md转docx", "生成word", or asks for an "editable document" from markdown source. license: MIT compatibility: > Requires Python 3.8+ and python-docx (`pip install python-docx`). Cross-platform: macOS, Windows, Linux. CJK fonts: macOS uses Songti SC, Windows uses SimSun, Linux uses Noto Serif CJK SC. metadata: author: contributors version: "0.4.0" tags: markdown docx word cjk python-docx typesetting --- # Word 大师 · Word Master This skill converts any Markdown file into a professionally styled Word document using Python's python-docx library. It shares the same theme palette as any2pdf and handles all CJK/Latin edge cases correctly. ## When to Use - User wants to convert `.md` → `.docx` (Word) - User needs an **editable** document (not PDF) - Document contains CJK characters mixed with Latin text - Document has fenced code blocks, markdown tables, or lists - User wants a cover page, table of contents, or watermark in their DOCX ## Quick Start ```bash python lov-any2docx/scripts/md2docx.py \ --input report.md \ --output report.docx \ --title "My Report" \ --author "Author Name" \ --theme warm-academic ``` All parameters except `--input` are optional — sensible defaults are applied. ## Pre-Conversion Options (MANDATORY) **IMPORTANT: You MUST use the `AskUserQuestion` tool to ask these questions BEFORE running the conversion. Do NOT list options as plain text — use the tool so the user gets a proper interactive prompt. Ask all options in a SINGLE `AskUserQuestion` call.** Use `AskUserQuestion` with the following template: ``` 开始转 Word!先帮你确认几个选项 👇 ━━━ 📐 设计风格 ━━━ a) 暖学术 — 陶土色调,温润典雅,适合人文/社科报告 b) 经典论文 — 棕色调,灵感源自 LaTeX classicthesis,适合学术论文 c) Tufte — 极简留白,深红点缀,适合数据叙事/技术写作 d) 期刊蓝 — 藏蓝严谨,灵感源自 IEEE,适合正式发表风格 e) 精装书 — 咖啡色调,书卷气,适合长篇专著/技术书 f) 中国红 — 朱红配暖纸,适合中文正式报告/白皮书 g) 水墨 — 纯灰黑,素雅克制,适合文学/设计类内容 h) GitHub — 蓝白极简,程序员熟悉的风格 i) Nord 冰霜 — 蓝灰北欧风,清爽现代 j) 海洋 — 青绿色调,清新自然 k) 投资报告 — 楷体+深红,专业严谨,适合投资/尽调报告 ━━━ 💧 水印 ━━━ 1) 不加 2) 自定义文字(如 "DRAFT"、"内部资料") 示例回复:"a, 水印:仅供内部参考" 直接说人话就行,不用记编号 😄 ``` ### Mapping User Choices to CLI Args | Choice | CLI arg | |--------|---------| | Design style a-k | `--theme` with value from table below | | Watermark text | `--watermark "文字"` | ### Theme Name Mapping | Choice | `--theme` value | |--------|----------------| | a) 暖学术 | `warm-academic` | | b) 经典论文 | `classic-thesis` | | c) Tufte | `tufte` | | d) 期刊蓝 | `ieee-journal` | | e) 精装书 | `elegant-book` | | f) 中国红 | `chinese-red` | | g) 水墨 | `ink-wash` | | h) GitHub | `github-light` | | i) Nord | `nord-frost` | | j) 海洋 | `ocean-breeze` | | k) 投资报告 | `invest-report` | ## Architecture ``` Markdown → Strip frontmatter → Preprocess (split merged headings) → Parse (code-fence-aware) → python-docx Document → .docx ``` Key components: 1. **CJK font switching**: `_split_mixed()` detects CJK runs and assigns Songti SC / SimSun / Noto CJK 2. **Inline markdown**: `_parse_inline()` handles **bold**, *italic*, `code`, [links](url) 3. **Images**: Local paths (relative to .md) and remote URLs — auto-downloaded and embedded via `add_picture()` 4. **Code blocks**: Shaded paragraph with monospace font and border 5. **Tables**: Header row with accent background, alternating row shading 6. **Watermark**: VML-based diagonal watermark in header (Word-native) 7. **TOC**: Field code with static fallback entries; `updateFields=true` triggers auto-refresh on open 8. **YAML frontmatter**: Automatically stripped (won't leak `status: draft` etc. into output) 9. **Adaptive cover title**: Font size scales down for long titles (36pt → 22pt) ## Configuration Reference | Argument | Default | Description | |----------|---------|-------------| | `--input` | (required) | Path to markdown file | | `--output` | `output.docx` | Output DOCX path | | `--title` | From first H1 | Document title for cover page | | `--subtitle` | `""` | Subtitle text | | `--author` | `""` | Author name | | `--date` | Today | Date string | | `--version` | `""` | Version string for cover | | `--watermark` | `""` | Watermark text (empty = none) | | `--theme` | `warm-academic` | Color theme name | | `--cover` | `true` | Generate cover page | | `--toc` | `true` | Generate table of contents | | `--header-title` | `""` | Report title in page header | | `--footer-left` | author | Brand/author in footer | | `--stats-line` | `""` | Stats on cover | | `--stats-line2` | `""` | Second stats line | | `--edition-line` | `""` | Edition line on cover | | `--code-max-lines` | `30` | Max lines per code block | ## Dependencies ```bash pip install python-docx --break-system-packages ``` ## Runtime context (shared) 运行前读取本 Skill 包的 `skill.yaml`,由宿主提供 `skill-runtime/v1` 上下文。字段解析顺序为:当前请求、项目上下文、个人 Preferences、品牌 Profile、通用默认值。 - 只使用 Manifest 声明的字段;Profile 保存公开品牌事实,Preferences 保存个人工作偏好。 - `required: true` 字段缺失时,按 Manifest 的问题配置向用户提出一个聚焦问题;用户明确同意后再保存回答。 - 报错提供可复制的 `context_id`、字段路径与来源,诊断内容避开秘密、完整私人路径和原始配置。 ## 通用反馈闭环 用户在 Skill 驱动任务中提出修改意见时,继续当前产物前必须执行: 1. 先判断意见是 `task-specific`(仅本次)还是 `reusable`(可跨任务复用)。 2. `task-specific` 只修改当前任务,不改 Skill。 3. `reusable` 先确定作用域:领域规则先更新对应 canonical Skill;适用于所有 Skill 的规则先更新共享规范。 4. 完成规则更新、版本、lint 与分发核验后,再把修改应用到当前任务。 5. `reusable` 修改会使此前的“确认”“继续”“发吧”失效;完成当前产物修改和回读后必须停下,等待用户下一步指示,不自动进入发布、提交或其他外部写入。 -
skill.yaml 830 B
schema: skill-manifest/v1 id: lov-any2docx version: "0.4.0" runtime: skill-runtime/v1 context: profile: fields: - path: identity.name required: true question: 如果本次输出需要品牌身份,请提供品牌名称。 - path: identity.logo required: false question: 如果需要使用品牌 Logo,请提供 Logo 地址或文件路径。 - path: brand.tone required: false question: 如果已有品牌语气或审美关键词,请提供它们。 preferences: namespace: lov_any2docx fields: - path: user.language required: false question: 希望使用哪种语言输出? - path: user.timezone required: false question: 需要使用哪个时区处理日期和时间? interaction: ask_missing: true max_questions: 1
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.














No comments yet.