Skip to content

Instantly share code, notes, and snippets.

@ivuorinen
Created August 21, 2025 11:24
Show Gist options
  • Select an option

  • Save ivuorinen/0c9ddb4818185f6e43c07d0624158c8e to your computer and use it in GitHub Desktop.

Select an option

Save ivuorinen/0c9ddb4818185f6e43c07d0624158c8e to your computer and use it in GitHub Desktop.
Reads a .md file, extracts ordered-list lines (e.g., "1. Text", "2) Text"), and outputs an A4-print-optimized HTML with a fixed-size card grid.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
make_cards.py
Reads a .md file, extracts ordered-list lines (e.g., "1. Text", "2) Text"),
and outputs an A4-print-optimized HTML with a fixed-size card grid.
Usage:
python3 make_cards.py input.md > cards.html
Options:
python3 make_cards.py -h
"""
from __future__ import annotations
import argparse
import html
import os
import re
import sys
from typing import List, Tuple
ORDERED_ITEM_RE = re.compile(r'^\s*(\d+)[\.\)]\s+(.*)\s*$', re.UNICODE)
# Minimal, safe inline markdown renderer for bold/italic/code/strike + links (text only)
# Order matters to avoid nested token confusion.
MD_CODE_RE = re.compile(r'`([^`]+)`') # `code`
MD_BOLD_RE = re.compile(r'\*\*([^*]+)\*\*') # **bold**
MD_ITAL1_RE = re.compile(r'(?<!\*)\*([^*]+)\*(?!\*)') # *italic* (not **)
MD_ITAL2_RE = re.compile(r'_(.+?)_') # _italic_
MD_STRIKE_RE = re.compile(r'~~([^~]+)~~') # ~~strike~~
MD_LINK_RE = re.compile(r'\[([^\]]+)\]\(([^)]+)\)') # [text](url)
def render_inline_markdown(text: str) -> str:
"""Render a very small subset of inline markdown safely.
Steps:
1) HTML-escape everything
2) Re-apply simple inline tags by regex
3) Strip links to just their text (better for print)
"""
s = html.escape(text, quote=True)
# Links: keep only the link text (no URL for print)
s = MD_LINK_RE.sub(lambda m: f'{m.group(1)}', s)
# Code
s = MD_CODE_RE.sub(lambda m: f'<code>{m.group(1)}</code>', s)
# Strike
s = MD_STRIKE_RE.sub(lambda m: f'<s>{m.group(1)}</s>', s)
# Bold
s = MD_BOLD_RE.sub(lambda m: f'<strong>{m.group(1)}</strong>', s)
# Italic (*...* and _..._)
s = MD_ITAL1_RE.sub(lambda m: f'<em>{m.group(1)}</em>', s)
s = MD_ITAL2_RE.sub(lambda m: f'<em>{m.group(1)}</em>', s)
return s
def parse_markdown_items(md_path: str) -> List[Tuple[int, str]]:
"""Return list of (number, text) for lines like '1. Text' or '2) Text'."""
items: List[Tuple[int, str]] = []
with open(md_path, 'r', encoding='utf-8') as f:
for line in f:
m = ORDERED_ITEM_RE.match(line)
if m:
num = int(m.group(1))
text = m.group(2).strip()
if text:
items.append((num, text))
return items
def generate_html(
title: str,
items: List[Tuple[int, str]],
cols: int,
card_height_cm: float,
gap_cm: float,
font_pt: int,
margin_cm: float,
border_color: str,
line_clamp: int,
show_numbers: bool,
landscape: bool,
) -> str:
page_size = f"A4 {'landscape' if landscape else 'portrait'}"
# Build cards HTML
cards_html = []
for num, raw_text in items:
content_html = render_inline_markdown(raw_text)
num_html = f'<div class="card-num">{html.escape(str(num))}</div>' if show_numbers else ''
card = f'''
<article class="card" role="note" aria-label="Card {num}">
{num_html}
<div class="content">{content_html}</div>
</article>'''.strip()
cards_html.append(card)
body = "\n".join(cards_html)
# HTML skeleton with print CSS
html_out = f'''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{html.escape(title)}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
/* Page + print setup */
@page {{
size: {page_size};
margin: {margin_cm:.2f}cm;
}}
:root {{
--cols: {cols};
--gap-cm: {gap_cm:.2f}cm;
--card-h-cm: {card_height_cm:.2f}cm;
--border-color: {border_color};
--base-font-pt: {font_pt}pt;
--line-clamp: {line_clamp};
}}
html, body {{
padding: 0;
margin: 0;
font-family: Arial, Helvetica, system-ui, "Segoe UI", Roboto, sans-serif;
font-size: var(--base-font-pt);
color: #111;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}}
.page {{
max-width: 21cm; /* For screen preview in portrait */
margin: 0 auto;
padding: 0;
}}
h1 {{
font-size: calc(var(--base-font-pt) * 1.3);
font-weight: 600;
margin: 0 0 0.6cm 0;
padding: 0;
}}
.grid {{
display: grid;
grid-template-columns: repeat(var(--cols), 1fr);
gap: var(--gap-cm);
align-items: start;
}}
.card {{
box-sizing: border-box;
height: var(--card-h-cm);
padding: 0.35cm;
border: 1px dashed var(--border-color);
line-height: 1.25;
overflow: hidden; /* keep physical dimensions uniform */
page-break-inside: avoid;
break-inside: avoid;
display: flex;
flex-direction: column;
justify-content: center; /* center text block vertically */
text-align: center;
position: relative; /* For absolute positioning of card-num */
}}
.card-num {{
font-size: 0.8em;
line-height: 1;
opacity: 0.75;
position: absolute;
top: 0.3rem;
right: 0.3rem;
padding: 0.3rem;
}}
.card > .content {{
display: -webkit-box;
-webkit-line-clamp: var(--line-clamp);
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}}
code {{
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 0.95em;
background: #f3f3f3;
padding: 0 0.15em;
border-radius: 2px;
}}
@media screen {{
body {{ padding: 1rem; }}
}}
@media print {{
h1 {{ display: none; }}
.page {{ max-width: none; }}
a[href]:after {{ content: ""; }}
}}
</style>
</head>
<body>
<main class="page">
<h1>{html.escape(title)}</h1>
<section class="grid" aria-label="Cards">
{body}
</section>
</main>
</body>
</html>
'''
return html_out
def main() -> None:
p = argparse.ArgumentParser(
description="Generate fixed-size UX research cards (HTML) from an ordered list in a .md file."
)
p.add_argument('input', help='Path to input .md file')
p.add_argument('-o', '--output', help='Path to output HTML (default: stdout)')
p.add_argument('--title', default='UX Research Cards', help='Document title')
p.add_argument('--cols', type=int, default=3, help='Number of grid columns (default: 3)')
p.add_argument('--card-height', type=float, default=3.5, help='Card height in cm (default: 3.5)')
p.add_argument('--gap', type=float, default=0, help='Gap between cards in cm (default: 0)')
p.add_argument('--font-pt', type=int, default=12, help='Base font size in pt (default: 12)')
p.add_argument('--margin', type=float, default=1.0, help='Page margin in cm (default: 1.0)')
p.add_argument('--border', default='#333', help='Card border color (default: #333)')
p.add_argument('--line-clamp', type=int, default=6, help='Max lines shown per card before clipping (default: 6)')
p.add_argument('--no-numbers', action='store_true', help='Hide item numbers on cards')
p.add_argument('--landscape', action='store_true', help='Use A4 landscape instead of portrait')
args = p.parse_args()
if not os.path.isfile(args.input):
print(f"Input file not found: {args.input}", file=sys.stderr)
sys.exit(1)
items = parse_markdown_items(args.input)
if not items:
print("No ordered-list items found in the input file.", file=sys.stderr)
sys.exit(2)
html_doc = generate_html(
title=args.title,
items=items,
cols=max(1, args.cols),
card_height_cm=max(1e-6, args.card_height),
gap_cm=max(0.0, args.gap),
font_pt=max(6, args.font_pt),
margin_cm=max(0.0, args.margin),
border_color=args.border,
line_clamp=max(1, args.line_clamp),
show_numbers=not args.no_numbers,
landscape=bool(args.landscape),
)
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
f.write(html_doc)
else:
sys.stdout.write(html_doc)
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment