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"^(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: 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"^(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"""
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