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:
|
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
|
import re
|
||||||
out, in_fence, in_html = [], False, False
|
out, in_fence, in_html = [], False, False
|
||||||
@@ -78,7 +84,15 @@ def _bulletize(text: str) -> str:
|
|||||||
in_html = True
|
in_html = True
|
||||||
elif re.match(r"^</(table|div|section|figure)>", stripped, re.I):
|
elif re.match(r"^</(table|div|section|figure)>", stripped, re.I):
|
||||||
in_html = False
|
in_html = False
|
||||||
if not in_fence and not in_html and line.startswith("- "):
|
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:]
|
line = "• " + line[2:]
|
||||||
out.append(line)
|
out.append(line)
|
||||||
return "\n".join(out)
|
return "\n".join(out)
|
||||||
@@ -430,12 +444,160 @@ def _render_hr(doc, node, style: dict):
|
|||||||
add_paragraph_bottom_border(p, color="d8d8d8", size="6")
|
add_paragraph_bottom_border(p, color="d8d8d8", size="6")
|
||||||
|
|
||||||
|
|
||||||
def convert_md_to_doc(md_file: Path, output_file: Path, style: str = "elegant") -> Path:
|
def _set_section_text(section, location: str, text: str, template: str):
|
||||||
"""Convert a single markdown file to DOCX.
|
"""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():
|
def main():
|
||||||
@@ -449,6 +611,15 @@ Examples:
|
|||||||
%(prog)s docs/ Convert all .md files in directory
|
%(prog)s docs/ Convert all .md files in directory
|
||||||
%(prog)s doc.md --style mono Use monospace font
|
%(prog)s doc.md --style mono Use monospace font
|
||||||
%(prog)s doc.md --style dark Use dark theme
|
%(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
|
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',
|
parser.add_argument('--style', choices=list(STYLES.keys()), default='elegant',
|
||||||
help='Style template (default: elegant)')
|
help='Style template (default: elegant)')
|
||||||
parser.add_argument('-q', '--quiet', action='store_true', help='Suppress output')
|
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()
|
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)
|
print(f"Converting: {md_file.name} -> {docx_file.name}...", end=' ', flush=True)
|
||||||
|
|
||||||
docx_file.parent.mkdir(parents=True, exist_ok=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:
|
if not args.quiet:
|
||||||
size_kb = docx_file.stat().st_size / 1024
|
size_kb = docx_file.stat().st_size / 1024
|
||||||
|
|||||||
@@ -26,6 +26,126 @@ from weasyprint.text.fonts import FontConfiguration
|
|||||||
|
|
||||||
# Style templates
|
# Style templates
|
||||||
STYLES = {
|
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": """
|
"elegant": """
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&display=swap');
|
||||||
@page {
|
@page {
|
||||||
@@ -99,13 +219,14 @@ STYLES = {
|
|||||||
th { font-weight: 400; color: #666; }
|
th { font-weight: 400; color: #666; }
|
||||||
ul, ol {
|
ul, ol {
|
||||||
margin: 0.3em 0;
|
margin: 0.3em 0;
|
||||||
padding-left: 0;
|
padding-left: 1.2em;
|
||||||
list-style-position: inside;
|
list-style-position: outside;
|
||||||
list-style-type: disc;
|
list-style-type: disc;
|
||||||
}
|
}
|
||||||
ol { list-style-type: decimal; }
|
ol { list-style-type: decimal; }
|
||||||
li {
|
li {
|
||||||
margin: 0.15em 0;
|
margin: 0.15em 0;
|
||||||
|
padding-left: 0.3em;
|
||||||
}
|
}
|
||||||
blockquote {
|
blockquote {
|
||||||
margin: 0.8em 0;
|
margin: 0.8em 0;
|
||||||
@@ -207,13 +328,14 @@ STYLES = {
|
|||||||
}
|
}
|
||||||
ul, ol {
|
ul, ol {
|
||||||
margin: 0.5em 0;
|
margin: 0.5em 0;
|
||||||
padding-left: 0;
|
padding-left: 1.2em;
|
||||||
list-style-position: inside;
|
list-style-position: outside;
|
||||||
list-style-type: disc;
|
list-style-type: disc;
|
||||||
}
|
}
|
||||||
ol { list-style-type: decimal; }
|
ol { list-style-type: decimal; }
|
||||||
li {
|
li {
|
||||||
margin: 0.2em 0;
|
margin: 0.2em 0;
|
||||||
|
padding-left: 0.3em;
|
||||||
}
|
}
|
||||||
blockquote {
|
blockquote {
|
||||||
border-left: 2px solid #ddd;
|
border-left: 2px solid #ddd;
|
||||||
@@ -283,8 +405,9 @@ STYLES = {
|
|||||||
padding: 0.5em 1em;
|
padding: 0.5em 1em;
|
||||||
margin: 1em 0;
|
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; }
|
ol { list-style-type: decimal; }
|
||||||
|
li { padding-left: 0.3em; }
|
||||||
a { color: #818cf8; }
|
a { color: #818cf8; }
|
||||||
hr { border: none; border-top: 1px solid #374151; margin: 2em 0; }
|
hr { border: none; border-top: 1px solid #374151; margin: 2em 0; }
|
||||||
""",
|
""",
|
||||||
@@ -364,13 +487,14 @@ STYLES = {
|
|||||||
}
|
}
|
||||||
ul, ol {
|
ul, ol {
|
||||||
margin: 0.5em 0;
|
margin: 0.5em 0;
|
||||||
padding-left: 0;
|
padding-left: 1.2em;
|
||||||
list-style-position: inside;
|
list-style-position: outside;
|
||||||
list-style-type: disc;
|
list-style-type: disc;
|
||||||
}
|
}
|
||||||
ol { list-style-type: decimal; }
|
ol { list-style-type: decimal; }
|
||||||
li {
|
li {
|
||||||
margin: 0.2em 0;
|
margin: 0.2em 0;
|
||||||
|
padding-left: 0.3em;
|
||||||
}
|
}
|
||||||
blockquote {
|
blockquote {
|
||||||
border-left: 3px solid #999;
|
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"^</(table|div|section|figure)>", 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"""
|
"""Convert a single markdown file to PDF"""
|
||||||
|
|
||||||
with open(md_file, 'r', encoding='utf-8') as f:
|
with open(md_file, 'r', encoding='utf-8') as f:
|
||||||
md_content = f.read()
|
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 = '<!--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(
|
html_content = markdown.markdown(
|
||||||
md_content,
|
md_content,
|
||||||
extensions=[
|
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.fenced_code',
|
||||||
'markdown.extensions.codehilite',
|
'markdown.extensions.codehilite',
|
||||||
'markdown.extensions.toc',
|
'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, '<div style="height: 0.8em;"></div>')
|
||||||
|
|
||||||
|
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"""<!DOCTYPE html>
|
full_html = f"""<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -427,7 +696,10 @@ def convert_md_to_pdf(md_file: Path, output_file: Path, style: str = "elegant")
|
|||||||
|
|
||||||
font_config = FontConfiguration()
|
font_config = FontConfiguration()
|
||||||
html_doc = HTML(string=full_html)
|
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
|
return output_file
|
||||||
|
|
||||||
@@ -444,8 +716,17 @@ Examples:
|
|||||||
%(prog)s docs/ -o pdf/ Convert directory to different output
|
%(prog)s docs/ -o pdf/ Convert directory to different output
|
||||||
%(prog)s doc.md --style mono Use monospace font
|
%(prog)s doc.md --style mono Use monospace font
|
||||||
%(prog)s doc.md --style dark Use dark theme
|
%(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',
|
parser.add_argument('--style', choices=list(STYLES.keys()), default='elegant',
|
||||||
help='Style template (default: elegant)')
|
help='Style template (default: elegant)')
|
||||||
parser.add_argument('-q', '--quiet', action='store_true', help='Suppress output')
|
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()
|
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)
|
print(f"Converting: {md_file.name} -> {pdf_file.name}...", end=' ', flush=True)
|
||||||
|
|
||||||
pdf_file.parent.mkdir(parents=True, exist_ok=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:
|
if not args.quiet:
|
||||||
size_kb = pdf_file.stat().st_size / 1024
|
size_kb = pdf_file.stat().st_size / 1024
|
||||||
|
|||||||
@@ -28,3 +28,19 @@ def test_tilde_fence_also_preserved():
|
|||||||
text = "~~~bash\n-x\n~~~\n"
|
text = "~~~bash\n-x\n~~~\n"
|
||||||
result = _bulletize(text)
|
result = _bulletize(text)
|
||||||
assert "-x" in result
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user