Created
August 21, 2025 11:24
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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