feat: --footer/--header with {page}/{pages} placeholders, center-aligned
md2pdf.py:
- Expose --footer and --header (was dead code before)
- Remove --forms flag (B2 fix — weasyprint 68.1 silently drops <input>)
- Substitution support: {page} → counter(page), {pages} → counter(pages)
- Center-aligned via @bottom-center / @top-center
md2doc.py:
- Add --footer and --header support via Word field codes (PAGE, NUMPAGES)
- Center-aligned paragraph in footer/header
- Same {page}/{pages} substitution API as md2pdf
Both tools:
- GFM task list support: - [ ] → ☐, - [x]/- [X] → ☑ (synced _bulletize)
- Help text explains placeholder syntax with examples
This commit is contained in:
@@ -63,9 +63,15 @@ COMMON = {
|
||||
|
||||
|
||||
def _bulletize(text: str) -> str:
|
||||
"""Turn markdown "- " list markers into bullets, skipping code/HTML blocks.
|
||||
"""Turn markdown list markers into bullets, skipping code/HTML blocks.
|
||||
|
||||
Copied verbatim from md2pdf.py per spec §3.3.
|
||||
Supports GFM task lists:
|
||||
- [ ] → ☐ (empty checkbox, U+2610)
|
||||
- [x] → ☑ (checked checkbox, U+2611)
|
||||
- [X] → ☑
|
||||
- → • (regular bullet)
|
||||
|
||||
Copied from md2pdf.py (kept in sync).
|
||||
"""
|
||||
import re
|
||||
out, in_fence, in_html = [], False, False
|
||||
@@ -78,8 +84,16 @@ def _bulletize(text: str) -> str:
|
||||
in_html = True
|
||||
elif re.match(r"^</(table|div|section|figure)>", stripped, re.I):
|
||||
in_html = False
|
||||
if not in_fence and not in_html and line.startswith("- "):
|
||||
line = "• " + line[2:]
|
||||
if not in_fence and not in_html:
|
||||
# GFM task list — checked
|
||||
if line.startswith("- [x] ") or line.startswith("- [X] "):
|
||||
line = "☑ " + line[6:]
|
||||
# GFM task list — unchecked
|
||||
elif line.startswith("- [ ] "):
|
||||
line = "☐ " + line[6:]
|
||||
# Regular bullet
|
||||
elif line.startswith("- "):
|
||||
line = "• " + line[2:]
|
||||
out.append(line)
|
||||
return "\n".join(out)
|
||||
|
||||
@@ -430,12 +444,160 @@ def _render_hr(doc, node, style: dict):
|
||||
add_paragraph_bottom_border(p, color="d8d8d8", size="6")
|
||||
|
||||
|
||||
def convert_md_to_doc(md_file: Path, output_file: Path, style: str = "elegant") -> Path:
|
||||
"""Convert a single markdown file to DOCX.
|
||||
def _set_section_text(section, location: str, text: str, template: str):
|
||||
"""Apply footer/header text to a docx Section.
|
||||
|
||||
Currently a stub — actual conversion logic in Task 4+.
|
||||
location: 'footer' or 'header'.
|
||||
text: literal text (may be empty — clears the field).
|
||||
template: original template with {page}/{pages} placeholders (used to detect
|
||||
whether to inject field codes).
|
||||
|
||||
For docx, page numbering uses Word field codes:
|
||||
{page} → PAGE field
|
||||
{pages} → NUMPAGES field
|
||||
|
||||
Other text is literal.
|
||||
"""
|
||||
raise NotImplementedError("md2doc conversion not yet implemented")
|
||||
import re
|
||||
if location == 'footer':
|
||||
target = section.footer
|
||||
else:
|
||||
target = section.header
|
||||
|
||||
# Clear default empty paragraph content (python-docx auto-creates one)
|
||||
if not target.paragraphs:
|
||||
target.add_paragraph()
|
||||
p = target.paragraphs[0]
|
||||
# Center-align the footer/header paragraph
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
# Clear existing runs
|
||||
for run in list(p.runs):
|
||||
run.text = ""
|
||||
|
||||
# Split template on placeholders
|
||||
parts = re.split(r'(\{page\}|\{pages\})', template)
|
||||
for part in parts:
|
||||
if part == '{page}':
|
||||
# Insert PAGE field code
|
||||
_add_field_code(p, 'PAGE')
|
||||
elif part == '{pages}':
|
||||
_add_field_code(p, 'NUMPAGES')
|
||||
elif part:
|
||||
p.add_run(part)
|
||||
|
||||
|
||||
def _add_field_code(paragraph, field: str):
|
||||
"""Insert a Word field code (PAGE, NUMPAGES) into a paragraph via XML."""
|
||||
run = paragraph.add_run()
|
||||
# Build the field structure: <w:fldChar begin/> <w:instrText>PAGE</w:instrText> <w:fldChar end/>
|
||||
fld_begin = OxmlElement('w:fldChar')
|
||||
fld_begin.set(qn('w:fldCharType'), 'begin')
|
||||
run._r.append(fld_begin)
|
||||
|
||||
instr_run = paragraph.add_run()
|
||||
instr = OxmlElement('w:instrText')
|
||||
instr.set(qn('xml:space'), 'preserve')
|
||||
instr.text = f' {field} '
|
||||
instr_run._r.append(instr)
|
||||
|
||||
sep_run = paragraph.add_run()
|
||||
fld_sep = OxmlElement('w:fldChar')
|
||||
fld_sep.set(qn('w:fldCharType'), 'separate')
|
||||
sep_run._r.append(fld_sep)
|
||||
|
||||
# Placeholder for the rendered value (Word will compute on open)
|
||||
placeholder_run = paragraph.add_run("?")
|
||||
# Mark as dirty so Word recalculates
|
||||
fld_dirty = OxmlElement('w:fldChar')
|
||||
fld_dirty.set(qn('w:fldCharType'), 'end')
|
||||
placeholder_run._r.append(fld_dirty)
|
||||
|
||||
# Set small font for footer/header runs
|
||||
for r in [run, instr_run, sep_run, placeholder_run]:
|
||||
r.font.size = Pt(7.5)
|
||||
r.font.color.rgb = RGBColor.from_string("999999")
|
||||
|
||||
|
||||
def convert_md_to_doc(md_file: Path, output_file: Path, style: str = "elegant",
|
||||
footer: str = "", header: str = "") -> Path:
|
||||
"""Convert a single markdown file to DOCX."""
|
||||
import re
|
||||
|
||||
style_cfg = STYLES.get(style, STYLES["elegant"])
|
||||
|
||||
with open(md_file, 'r', encoding='utf-8') as f:
|
||||
md_content = f.read()
|
||||
|
||||
# Reuse _bulletize and BLANK_MARKER logic from md2pdf (copied, not imported)
|
||||
md_content = _bulletize(md_content)
|
||||
|
||||
BLANK_MARKER = '<!--blank-line-->'
|
||||
md_content = re.sub(
|
||||
r'\n{3,}',
|
||||
lambda m: '\n\n' + (BLANK_MARKER + '\n\n') * (len(m.group(0)) - 2),
|
||||
md_content
|
||||
)
|
||||
|
||||
html_content = markdown.markdown(
|
||||
md_content,
|
||||
extensions=[
|
||||
'markdown.extensions.tables',
|
||||
'markdown.extensions.fenced_code',
|
||||
'markdown.extensions.toc',
|
||||
'markdown.extensions.attr_list',
|
||||
'markdown.extensions.md_in_html',
|
||||
'markdown.extensions.nl2br',
|
||||
]
|
||||
)
|
||||
html_content = html_content.replace(
|
||||
BLANK_MARKER, '<div style="height: 0.8em;"></div>'
|
||||
)
|
||||
|
||||
soup = BeautifulSoup(html_content, "lxml")
|
||||
doc = Document()
|
||||
|
||||
# Walk top-level elements in <body>
|
||||
body = soup.find("body") or soup
|
||||
for element in body.children:
|
||||
if not hasattr(element, "name") or element.name is None:
|
||||
continue
|
||||
name = element.name
|
||||
|
||||
if name in ("h1", "h2", "h3", "h4"):
|
||||
_render_heading(doc, element, level=int(name[1]), style=style_cfg)
|
||||
elif name == "p":
|
||||
_render_paragraph(doc, element, style=style_cfg)
|
||||
elif name in ("ul", "ol"):
|
||||
_render_list(doc, element, style=style_cfg)
|
||||
elif name == "table":
|
||||
_render_table(doc, element, style=style_cfg)
|
||||
elif name == "pre":
|
||||
_render_pre(doc, element, style=style_cfg)
|
||||
elif name == "blockquote":
|
||||
_render_blockquote(doc, element, style=style_cfg)
|
||||
elif name == "hr":
|
||||
_render_hr(doc, element, style=style_cfg)
|
||||
elif name == "img":
|
||||
_warn_skip_image(element)
|
||||
elif name == "figure":
|
||||
img = element.find("img")
|
||||
if img:
|
||||
_warn_skip_image(img)
|
||||
else:
|
||||
_render_paragraph(doc, element, style=style_cfg)
|
||||
# else: silently skip unknown elements (divs, spans, etc.)
|
||||
|
||||
# Apply footer/header to all sections (typically just one)
|
||||
if footer or header:
|
||||
for section in doc.sections:
|
||||
if footer:
|
||||
_set_section_text(section, 'footer', footer, footer)
|
||||
if header:
|
||||
_set_section_text(section, 'header', header, header)
|
||||
|
||||
doc.save(output_file)
|
||||
return output_file
|
||||
|
||||
|
||||
def main():
|
||||
@@ -447,8 +609,17 @@ Examples:
|
||||
%(prog)s document.md Convert single file (elegant style)
|
||||
%(prog)s document.md -o report.docx Convert with custom output name
|
||||
%(prog)s docs/ Convert all .md files in directory
|
||||
%(prog)s doc.md --style mono Use monospace font
|
||||
%(prog)s doc.md --style dark Use dark theme
|
||||
%(prog)s doc.md --style mono Use monospace font
|
||||
%(prog)s doc.md --style dark Use dark theme
|
||||
%(prog)s contract.md --footer "Confidential" Add footer (static text)
|
||||
%(prog)s doc.md --header "Project X" Add header (static text)
|
||||
%(prog)s doc.md --footer "Page {page}" Add footer with page counter
|
||||
%(prog)s doc.md --footer "Page {page} of {pages}" Page counter with total
|
||||
|
||||
Footer/Header placeholders (only when --footer/--header is used):
|
||||
{page} current page number (e.g. "3")
|
||||
{pages} total page count (e.g. "12")
|
||||
Literal text is rendered as-is. Mix freely: "Page {page} of {pages} — Confidential"
|
||||
|
||||
Available styles: elegant (default), default, dark, mono, report
|
||||
"""
|
||||
@@ -459,6 +630,14 @@ Available styles: elegant (default), default, dark, mono, report
|
||||
parser.add_argument('--style', choices=list(STYLES.keys()), default='elegant',
|
||||
help='Style template (default: elegant)')
|
||||
parser.add_argument('-q', '--quiet', action='store_true', help='Suppress output')
|
||||
parser.add_argument('--footer',
|
||||
help='Custom footer text (center-aligned). '
|
||||
'Supports {page} and {pages} placeholders: '
|
||||
'"Page {page} of {pages}"')
|
||||
parser.add_argument('--header',
|
||||
help='Custom header text (center-aligned). '
|
||||
'Supports {page} and {pages} placeholders: '
|
||||
'"Section X — page {page}"')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -508,7 +687,9 @@ Available styles: elegant (default), default, dark, mono, report
|
||||
print(f"Converting: {md_file.name} -> {docx_file.name}...", end=' ', flush=True)
|
||||
|
||||
docx_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
convert_md_to_doc(md_file, docx_file, args.style)
|
||||
convert_md_to_doc(md_file, docx_file, args.style,
|
||||
footer=args.footer or "",
|
||||
header=args.header or "")
|
||||
|
||||
if not args.quiet:
|
||||
size_kb = docx_file.stat().st_size / 1024
|
||||
|
||||
Reference in New Issue
Block a user