From 5a62572f5543c61f6d176c6d27b551ec3213615d Mon Sep 17 00:00:00 2001 From: Sebastian Petrescu Date: Tue, 28 Jul 2026 11:02:23 +0300 Subject: [PATCH] feat: --footer/--header with {page}/{pages} placeholders, center-aligned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit md2pdf.py: - Expose --footer and --header (was dead code before) - Remove --forms flag (B2 fix — weasyprint 68.1 silently drops ) - 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 --- md2doc.py | 203 +++++++++++++++++++++++-- md2pdf.py | 329 +++++++++++++++++++++++++++++++++++++--- tests/test_bulletize.py | 16 ++ 3 files changed, 518 insertions(+), 30 deletions(-) diff --git a/md2doc.py b/md2doc.py index c3f6fbc..4b253da 100644 --- a/md2doc.py +++ b/md2doc.py @@ -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"^", 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: PAGE + 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 = '' + 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, '
' + ) + + soup = BeautifulSoup(html_content, "lxml") + doc = Document() + + # Walk top-level elements in + 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 diff --git a/md2pdf.py b/md2pdf.py index c59b88f..2885d3c 100755 --- a/md2pdf.py +++ b/md2pdf.py @@ -26,6 +26,126 @@ from weasyprint.text.fonts import FontConfiguration # Style templates STYLES = { + "report": """ + @page { + size: A4; + margin: 2cm 1.8cm 2cm 1.8cm; + @bottom-center { + content: counter(page); + font-family: Helvetica, Arial, sans-serif; + font-size: 8.5pt; + color: #777; + } + } + body { + font-family: Helvetica, Arial, sans-serif; + font-size: 9.5pt; + line-height: 1.5; + color: #1a1a1a; + } + h1 { + font-size: 17pt; + color: #1e3a8a; + border-bottom: 2.5px solid #1e3a8a; + padding-bottom: 5px; + margin-top: 26px; + page-break-after: avoid; + } + h1:first-of-type { margin-top: 0; } + h2 { + font-size: 13pt; + color: #1e3a8a; + margin-top: 20px; + page-break-after: avoid; + } + h3 { + font-size: 11pt; + color: #333; + margin-top: 15px; + page-break-after: avoid; + } + h4 { + font-size: 10pt; + color: #444; + margin-top: 12px; + page-break-after: avoid; + } + p { margin: 0.5em 0; } + table { + border-collapse: collapse; + width: 100%; + margin: 11px 0; + font-size: 8.5pt; + page-break-inside: avoid; + } + th { + background: #1e3a8a; + color: #fff; + text-align: left; + padding: 5px 7px; + font-weight: 600; + } + td { + border-bottom: 1px solid #e0e0e0; + padding: 5px 7px; + vertical-align: top; + } + tr:nth-child(even) td { background: #f7f8fa; } + /* tabele fără antet (perechi etichetă/valoare) */ + table thead tr:has(th:empty) { display: none; } + code { + font-family: "SF Mono", Menlo, monospace; + font-size: 8pt; + background: #f0f2f5; + padding: 1px 3px; + border-radius: 2px; + } + pre { + background: #f7f8fa; + border-left: 3px solid #1e3a8a; + padding: 0.8em; + font-size: 8pt; + overflow-x: auto; + } + pre code { background: none; padding: 0; } + blockquote { + border-left: 3px solid #1e3a8a; + margin: 11px 0; + padding: 5px 12px; + background: #f7f8fa; + font-size: 8.5pt; + } + ul, ol { + margin: 0.4em 0; + padding-left: 1.2em; + } + li { margin: 0.15em 0; } + a { + color: #1e3a8a; + text-decoration: none; + border-bottom: 1px solid #aab; + } + strong { color: #111; font-weight: 700; } + em { font-style: italic; } + hr { border: none; border-top: 1px solid #d8d8d8; margin: 20px 0; } + figure { + margin: 14px 0; + page-break-inside: avoid; + text-align: center; + } + figure img { + width: 100%; + max-height: 21cm; + object-fit: contain; + border: 1px solid #ccc; + } + figcaption { + font-size: 8pt; + color: #555; + margin-top: 5px; + font-style: italic; + } + """, "elegant": """ @import url('https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&display=swap'); @page { @@ -99,13 +219,14 @@ STYLES = { th { font-weight: 400; color: #666; } ul, ol { margin: 0.3em 0; - padding-left: 0; - list-style-position: inside; + padding-left: 1.2em; + list-style-position: outside; list-style-type: disc; } ol { list-style-type: decimal; } li { margin: 0.15em 0; + padding-left: 0.3em; } blockquote { margin: 0.8em 0; @@ -207,13 +328,14 @@ STYLES = { } ul, ol { margin: 0.5em 0; - padding-left: 0; - list-style-position: inside; + padding-left: 1.2em; + list-style-position: outside; list-style-type: disc; } ol { list-style-type: decimal; } li { margin: 0.2em 0; + padding-left: 0.3em; } blockquote { border-left: 2px solid #ddd; @@ -283,8 +405,9 @@ STYLES = { padding: 0.5em 1em; margin: 1em 0; } - ul, ol { padding-left: 0; list-style-position: inside; list-style-type: disc; } + ul, ol { padding-left: 1.2em; list-style-position: outside; list-style-type: disc; } ol { list-style-type: decimal; } + li { padding-left: 0.3em; } a { color: #818cf8; } hr { border: none; border-top: 1px solid #374151; margin: 2em 0; } """, @@ -364,13 +487,14 @@ STYLES = { } ul, ol { margin: 0.5em 0; - padding-left: 0; - list-style-position: inside; + padding-left: 1.2em; + list-style-position: outside; list-style-type: disc; } ol { list-style-type: decimal; } li { margin: 0.2em 0; + padding-left: 0.3em; } blockquote { border-left: 3px solid #999; @@ -395,12 +519,96 @@ STYLES = { } -def convert_md_to_pdf(md_file: Path, output_file: Path, style: str = "elegant") -> Path: +COMMON_CSS = """ +/* ═══ reguli comune (injectate automat) ═══ */ +/* NU seta table-layout global: ar suprascrie style="table-layout:fixed" + scris inline pe un tabel anume (specificitate egală, ultima regulă câștigă). + Tabelele care au nevoie de lățimi exacte pe coloane folosesc + style="table-layout:fixed" sau class="fixed". */ +table.fixed { table-layout: fixed; } +td, th { vertical-align: top; } +tr, td, th { page-break-inside: avoid; } +thead { display: table-header-group; } +h1, h2, h3, h4 { page-break-after: avoid; break-after: avoid; } +p { orphans: 3; widows: 3; } +pre, blockquote { page-break-inside: avoid; } +.page-break { page-break-before: always; } + +/* ═══ câmpuri completabile (--forms) ═══ */ +input[type="text"], textarea, input, select { + appearance: auto; + font-family: inherit; /* altfel câmpurile PDF cad pe Helvetica */ + border: none; + border-bottom: 1px solid #666; + background: transparent; + font-family: inherit; + font-size: inherit; + color: inherit; + padding: 0 2px; + min-width: 4em; +} +td input[type="text"], th input[type="text"] { + border-bottom: none; + width: 100%; + display: block; +} +""" + + +def _bulletize(text: str) -> str: + """Turn markdown list markers into bullets, skipping code/HTML blocks. + + Supports GFM task lists: + - [ ] \u2192 \u2610 (empty checkbox, U+2610) + - [x] \u2192 \u2611 (checked checkbox, U+2611) + - [X] \u2192 \u2611 + - \u2192 \u2022 (regular bullet) + """ + import re + out, in_fence, in_html = [], False, False + for line in text.split("\n"): + stripped = line.lstrip() + if stripped.startswith("```") or stripped.startswith("~~~"): + in_fence = not in_fence + elif not in_fence: + if re.match(r"^<(table|div|section|figure)\b", stripped, re.I): + in_html = True + elif re.match(r"^", stripped, re.I): + in_html = False + if not in_fence and not in_html: + # GFM task list \u2014 checked + if line.startswith("- [x] ") or line.startswith("- [X] "): + line = "\u2611 " + line[6:] + # GFM task list \u2014 unchecked + elif line.startswith("- [ ] "): + line = "\u2610 " + line[6:] + # Regular bullet + elif line.startswith("- "): + line = "\u2022 " + line[2:] + out.append(line) + return "\n".join(out) + + +def convert_md_to_pdf(md_file: Path, output_file: Path, style: str = "report", + footer: str = "", header: str = "") -> Path: """Convert a single markdown file to PDF""" with open(md_file, 'r', encoding='utf-8') as f: md_content = f.read() + import re + + # Convert dash lists to bullet char, but NEVER inside fenced code blocks + # or raw HTML blocks — there a leading "- " is literal content (shell + # flags, YAML, diffs), not a list marker. + md_content = _bulletize(md_content) + + # Preserve multiple blank lines: 3+ consecutive newlines get a marker comment + # that survives markdown processing without affecting layout (floats, divs, etc.) + # Markers are replaced with spacing divs AFTER markdown→HTML conversion. + BLANK_MARKER = '' + 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=[ @@ -408,11 +616,72 @@ def convert_md_to_pdf(md_file: Path, output_file: Path, style: str = "elegant") 'markdown.extensions.fenced_code', 'markdown.extensions.codehilite', 'markdown.extensions.toc', - 'markdown.extensions.nl2br' + 'markdown.extensions.attr_list', + 'markdown.extensions.md_in_html', + 'markdown.extensions.nl2br', ] ) - css = STYLES.get(style, STYLES["elegant"]) + # Replace markers with visible spacing + html_content = html_content.replace(BLANK_MARKER, '
') + + css = STYLES.get(style, STYLES["report"]) + COMMON_CSS + + # Footer/Header custom: injectat în @bottom-right / @top-right al paginii. + # Substituții template: + # {page} → counter(page) — numărul paginii curente + # {pages} → counter(pages) — numărul total de pagini + # Exemple: + # --footer "Page {page}" → "Page 3" + # --footer "Page {page} of {pages}" → "Page 3 of 12" + # --footer "Confidential" → text static, fără counter + # --header "{page}" → doar numărul de pagină + def _render_template(text: str) -> str: + """Convert {page}/{pages} placeholders to CSS counter() expressions. + + Returns a CSS content: value (string parts quoted, counters unquoted). + Escapes backslash and double-quote in literal text portions. + """ + import re + # Split on placeholders, keeping them + parts = re.split(r'(\{page\}|\{pages\})', text) + rendered = [] + for part in parts: + if part == '{page}': + rendered.append('counter(page)') + elif part == '{pages}': + rendered.append('counter(pages)') + elif part: + # Literal text — escape for CSS string + escaped = part.replace('\\', '\\\\').replace('"', '\\"') + rendered.append(f'"{escaped}"') + return ' '.join(rendered) if rendered else '""' + + if footer: + footer_css = _render_template(footer) + css += f""" + @page {{ + @bottom-center {{ + content: {footer_css}; + font-family: Helvetica, Arial, sans-serif; + font-size: 7.5pt; + color: #999; + }} + }} + """ + + if header: + header_css = _render_template(header) + css += f""" + @page {{ + @top-center {{ + content: {header_css}; + font-family: Helvetica, Arial, sans-serif; + font-size: 7.5pt; + color: #999; + }} + }} + """ full_html = f""" @@ -427,7 +696,10 @@ def convert_md_to_pdf(md_file: Path, output_file: Path, style: str = "elegant") font_config = FontConfiguration() html_doc = HTML(string=full_html) - html_doc.write_pdf(output_file, font_config=font_config) + html_doc.write_pdf( + output_file, + font_config=font_config, + ) return output_file @@ -438,14 +710,23 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: - %(prog)s document.md Convert single file (elegant style) - %(prog)s document.md -o report.pdf Convert with custom output name - %(prog)s docs/ Convert all .md files in directory - %(prog)s docs/ -o pdf/ Convert directory to different output - %(prog)s doc.md --style mono Use monospace font - %(prog)s doc.md --style dark Use dark theme + %(prog)s document.md Convert single file (elegant style) + %(prog)s document.md -o report.pdf Convert with custom output name + %(prog)s docs/ Convert all .md files in directory + %(prog)s docs/ -o pdf/ Convert directory to different output + %(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 -Available styles: elegant (default), default, dark, mono +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 """ ) @@ -454,6 +735,14 @@ Available styles: elegant (default), default, dark, mono 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, @bottom-center). ' + 'Supports {page} and {pages} placeholders: ' + '"Page {page} of {pages}"') + parser.add_argument('--header', + help='Custom header text (center-aligned, @top-center). ' + 'Supports {page} and {pages} placeholders: ' + '"Section X — page {page}"') args = parser.parse_args() @@ -505,7 +794,9 @@ Available styles: elegant (default), default, dark, mono print(f"Converting: {md_file.name} -> {pdf_file.name}...", end=' ', flush=True) pdf_file.parent.mkdir(parents=True, exist_ok=True) - convert_md_to_pdf(md_file, pdf_file, args.style) + convert_md_to_pdf(md_file, pdf_file, args.style, + footer=args.footer or "", + header=args.header or "") if not args.quiet: size_kb = pdf_file.stat().st_size / 1024 diff --git a/tests/test_bulletize.py b/tests/test_bulletize.py index 6ae9eaa..1e5cd88 100644 --- a/tests/test_bulletize.py +++ b/tests/test_bulletize.py @@ -28,3 +28,19 @@ def test_tilde_fence_also_preserved(): text = "~~~bash\n-x\n~~~\n" result = _bulletize(text) assert "-x" in result + + +def test_unchecked_task_list_becomes_empty_checkbox(): + text = "- [ ] todo item\n" + result = _bulletize(text) + assert "☐ todo item" in result + assert "[ ]" not in result + + +def test_checked_task_list_becomes_checked_checkbox(): + text = "- [x] done item\n- [X] also done\n" + result = _bulletize(text) + assert "☑ done item" in result + assert "☑ also done" in result + assert "[x]" not in result + assert "[X]" not in result