feat: 增加skill
This commit is contained in:
parent
9bc28e852c
commit
2723adf2d2
|
|
@ -1,6 +1,7 @@
|
||||||
# Ignore everything by default
|
# Ignore everything by default
|
||||||
*
|
*
|
||||||
|
|
||||||
|
!*/
|
||||||
# Whitelist specific files and directories
|
# Whitelist specific files and directories
|
||||||
!.gitignore
|
!.gitignore
|
||||||
!SKILL.md
|
!SKILL.md
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
---
|
||||||
|
name: image-generation
|
||||||
|
description: Generate images using MiniMax API with support for custom prompts, aspect ratios, and multiple image generation.
|
||||||
|
metadata: {"clawdbot":{"emoji":"🎨","os":["linux","darwin","win32"]}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# MiniMax Image Generation SKILL
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Generate images using MiniMax API. Supports custom prompts, aspect ratios, generation count, and more.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/run.py --api-key "your-api-key" --prompt "your-prompt" [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Arguments
|
||||||
|
|
||||||
|
| Argument | Type | Required | Default | Description |
|
||||||
|
|----------|------|----------|---------|-------------|
|
||||||
|
| `--api-key` | string | Yes | - | MiniMax API key (can also be set via `MINIMAX_API_KEY` env var) |
|
||||||
|
| `--prompt` | string | Yes | - | Image generation prompt |
|
||||||
|
| `--model` | string | No | `image-01` | Image generation model |
|
||||||
|
| `--aspect-ratio` | string | No | `1:1` | Image aspect ratio (e.g., 16:9, 1:1, 9:16) |
|
||||||
|
| `--response-format` | string | No | `url` | Response format (`url` or `base64`) |
|
||||||
|
| `--n` | int | No | `1` | Number of images to generate (1-3) |
|
||||||
|
| `--prompt-optimizer` | bool | No | `false` | Enable prompt optimizer |
|
||||||
|
| `--subject-reference` | string | No | - | Reference image URL or local file path for image-to-image generation |
|
||||||
|
| `--subject-type` | string | No | `character` | Subject reference type (character, product, logo, video_subject, other) |
|
||||||
|
| `--seed` | int | No | - | Random seed for reproducible generation |
|
||||||
|
| `--output-dir` | string | No | `./output` | Output directory for images |
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
- **Endpoint**: `POST https://api.minimaxi.com/v1/image_generation`
|
||||||
|
- **Auth**: Bearer Token
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
python scripts/run.py --api-key "sk-xxx" --prompt "A sunset over the ocean"
|
||||||
|
|
||||||
|
# Generate multiple images
|
||||||
|
python scripts/run.py --api-key "sk-xxx" --prompt "A man in a white t-shirt, full-body, standing front view, outdoors" --n 3 --aspect-ratio "16:9"
|
||||||
|
|
||||||
|
# Enable prompt optimizer
|
||||||
|
python scripts/run.py --api-key "sk-xxx" --prompt "sunset ocean" --prompt-optimizer true
|
||||||
|
|
||||||
|
# Image-to-image generation with reference image URL
|
||||||
|
python scripts/run.py --api-key "sk-xxx" --prompt "A girl looking into the distance from a library window" --subject-reference "https://example.com/reference.jpg" --subject-type "character" --n 2
|
||||||
|
|
||||||
|
# Image-to-image with local file
|
||||||
|
python scripts/run.py --api-key "sk-xxx" --prompt "A girl looking into the distance from a library window" --subject-reference "./my_character.jpg" --subject-type "character"
|
||||||
|
|
||||||
|
# Image-to-image with product reference
|
||||||
|
python scripts/run.py --api-key "sk-xxx" --prompt "A beautiful product shot" --subject-reference "https://example.com/product.jpg" --subject-type "product"
|
||||||
|
|
||||||
|
# Use seed for reproducible generation
|
||||||
|
python scripts/run.py --api-key "sk-xxx" --prompt "A sunset over the ocean" --seed 42
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Images are saved to the specified output directory with the naming format: `generated_image_{index}_{timestamp}.{ext}`.
|
||||||
|
|
@ -0,0 +1,287 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# @skill: image-generation
|
||||||
|
|
||||||
|
"""
|
||||||
|
MiniMax Image Generation Script
|
||||||
|
Generate images using MiniMax API
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
"""Parse command line arguments"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Generate images using MiniMax API",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--api-key",
|
||||||
|
type=str,
|
||||||
|
required=False,
|
||||||
|
help="MiniMax API key (can also be set via MINIMAX_API_KEY environment variable)"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--prompt",
|
||||||
|
type=str,
|
||||||
|
required=True,
|
||||||
|
help="Image generation prompt"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--model",
|
||||||
|
type=str,
|
||||||
|
default="image-01",
|
||||||
|
help="Image generation model (default: image-01)"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--aspect-ratio",
|
||||||
|
type=str,
|
||||||
|
default="1:1",
|
||||||
|
help="Image aspect ratio (default: 1:1, options: 16:9, 9:16, etc.)"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--response-format",
|
||||||
|
type=str,
|
||||||
|
default="url",
|
||||||
|
choices=["url", "base64"],
|
||||||
|
help="Response format (default: url)"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--n",
|
||||||
|
type=int,
|
||||||
|
default=1,
|
||||||
|
choices=[1, 2, 3],
|
||||||
|
help="Number of images to generate (default: 1, max: 3)"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--prompt-optimizer",
|
||||||
|
type=lambda x: x.lower() == "true",
|
||||||
|
default=False,
|
||||||
|
help="Enable prompt optimizer (default: false)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Image-to-image (subject reference) parameters
|
||||||
|
parser.add_argument(
|
||||||
|
"--subject-reference",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help="Reference image URL or local file path for image-to-image generation"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--subject-type",
|
||||||
|
type=str,
|
||||||
|
default="character",
|
||||||
|
choices=["character", "product", "logo", "video_subject", "other"],
|
||||||
|
help="Subject reference type (default: character)"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--seed",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Random seed for reproducible generation (optional)"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--output-dir",
|
||||||
|
type=str,
|
||||||
|
default="./output",
|
||||||
|
help="Output directory for images (default: ./output)"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--api-base",
|
||||||
|
type=str,
|
||||||
|
default="https://api.minimaxi.com",
|
||||||
|
help="API base URL (default: https://api.minimaxi.com)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def download_image(url: str, output_path: str) -> bool:
|
||||||
|
"""Download image to local file"""
|
||||||
|
try:
|
||||||
|
response = requests.get(url, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
with open(output_path, "wb") as f:
|
||||||
|
f.write(response.content)
|
||||||
|
|
||||||
|
print(f" [OK] Saved: {output_path}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [FAIL] Download failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def read_local_image(file_path: str) -> str:
|
||||||
|
"""
|
||||||
|
Read local image file and return as base64 encoded string.
|
||||||
|
Returns the base64 string or None if failed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
image_data = f.read()
|
||||||
|
import base64
|
||||||
|
return base64.b64encode(image_data).decode("utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [FAIL] Failed to read local image: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_subject_reference(subject_ref: str, subject_type: str) -> dict:
|
||||||
|
"""
|
||||||
|
Build subject_reference object from URL or local file path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subject_ref: URL or local file path
|
||||||
|
subject_type: Type of subject (character, product, logo, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with subject_reference structure
|
||||||
|
"""
|
||||||
|
# Check if it's a URL or local file
|
||||||
|
if subject_ref.startswith(("http://", "https://")):
|
||||||
|
# It's a URL
|
||||||
|
return {
|
||||||
|
"type": subject_type,
|
||||||
|
"image_file": subject_ref
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# It's a local file - convert to base64
|
||||||
|
print(f" Processing local image: {subject_ref}")
|
||||||
|
base64_data = read_local_image(subject_ref)
|
||||||
|
if base64_data:
|
||||||
|
return {
|
||||||
|
"type": subject_type,
|
||||||
|
"image_file": f"data:image/jpeg;base64,{base64_data}"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def generate_images(args):
|
||||||
|
"""Call MiniMax API to generate images"""
|
||||||
|
url = f"{args.api_base}/v1/image_generation"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {args.api_key}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": args.model,
|
||||||
|
"prompt": args.prompt,
|
||||||
|
"aspect_ratio": args.aspect_ratio,
|
||||||
|
"response_format": args.response_format,
|
||||||
|
"n": args.n,
|
||||||
|
"prompt_optimizer": args.prompt_optimizer
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add seed if provided
|
||||||
|
if args.seed is not None:
|
||||||
|
payload["seed"] = args.seed
|
||||||
|
|
||||||
|
# Add subject_reference for image-to-image generation
|
||||||
|
if args.subject_reference:
|
||||||
|
subject_ref = build_subject_reference(args.subject_reference, args.subject_type)
|
||||||
|
if subject_ref:
|
||||||
|
payload["subject_reference"] = [subject_ref]
|
||||||
|
else:
|
||||||
|
print("Warning: Failed to process subject reference, continuing without it.")
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"MiniMax Image Generation")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
print(f"Model: {args.model}")
|
||||||
|
print(f"Prompt: {args.prompt}")
|
||||||
|
print(f"Aspect Ratio: {args.aspect_ratio}")
|
||||||
|
print(f"Number: {args.n}")
|
||||||
|
print(f"Prompt Optimizer: {'Enabled' if args.prompt_optimizer else 'Disabled'}")
|
||||||
|
if args.seed is not None:
|
||||||
|
print(f"Seed: {args.seed}")
|
||||||
|
if args.subject_reference:
|
||||||
|
print(f"Subject Reference: {args.subject_type} - {args.subject_reference}")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("Generating images...")
|
||||||
|
response = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
if result.get("base_resp", {}).get("status_code") != 0:
|
||||||
|
error_msg = result.get("base_resp", {}).get("status_msg", "Unknown error")
|
||||||
|
print(f"API Error: {error_msg}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Create output directory
|
||||||
|
os.makedirs(args.output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Process returned images
|
||||||
|
image_urls = result.get("data", {}).get("image_urls", [])
|
||||||
|
metadata = result.get("metadata", {})
|
||||||
|
|
||||||
|
print(f"\nSuccessfully generated {metadata.get('success_count', len(image_urls))} images:")
|
||||||
|
|
||||||
|
timestamp = int(time.time())
|
||||||
|
saved_count = 0
|
||||||
|
|
||||||
|
for i, image_url in enumerate(image_urls, 1):
|
||||||
|
# Extract file extension from URL
|
||||||
|
parsed = urlparse(image_url)
|
||||||
|
path = parsed.path
|
||||||
|
ext = os.path.splitext(path)[1] if "." in path else ".jpeg"
|
||||||
|
|
||||||
|
filename = f"generated_image_{i}_{timestamp}{ext}"
|
||||||
|
output_path = os.path.join(args.output_dir, filename)
|
||||||
|
|
||||||
|
if download_image(image_url, output_path):
|
||||||
|
saved_count += 1
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Done! Successfully saved {saved_count}/{len(image_urls)} images")
|
||||||
|
print(f"Output directory: {os.path.abspath(args.output_dir)}")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
return saved_count > 0
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"\nRequest Error: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\nUnexpected Error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main function"""
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
# Get API key from argument or environment variable (optional)
|
||||||
|
if not args.api_key:
|
||||||
|
args.api_key = os.environ.get("MINIMAX_API_KEY", "")
|
||||||
|
|
||||||
|
# If still no API key, prompt user to enter it
|
||||||
|
if not args.api_key:
|
||||||
|
print("Error: API key not int ENV, and it is required.")
|
||||||
|
else:
|
||||||
|
generate_images(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
---
|
||||||
|
name: leetcode-daily-card
|
||||||
|
description: A Python script that automatically fetches the daily LeetCode challenge and renders it as a PNG card or Markdown document.
|
||||||
|
metadata: {"clawdbot":{"emoji":"📋","os":["linux","darwin","win32"]}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# LeetCode Daily Card Generator
|
||||||
|
|
||||||
|
A Python script that automatically fetches the daily LeetCode challenge and renders it as a PNG card or Markdown document.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Automatic Daily Fetch**: Retrieves the current daily LeetCode problem via GraphQL API
|
||||||
|
- **Two-Step Rendering**: HTML → Markdown → PNG for optimal text rendering
|
||||||
|
- **CJK Support**: Uses system CJK fonts (Microsoft YaHei, SimHei, etc.) for Chinese characters
|
||||||
|
- **Multiple Output Formats**: Supports `.png` for image cards and `.md` for Markdown documents
|
||||||
|
- **Clean Unicode Handling**: Removes zero-width spaces and other invisible Unicode characters
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install Pillow html2text
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **Pillow**: For PNG/JPG image rendering
|
||||||
|
- **html2text**: For converting HTML content to Markdown
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate PNG card
|
||||||
|
python scripts/run.py output.png
|
||||||
|
|
||||||
|
# Generate Markdown document
|
||||||
|
python scripts/run.py output.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output Formats
|
||||||
|
|
||||||
|
### PNG Card
|
||||||
|
- Blue gradient header
|
||||||
|
- White card body with rounded corners
|
||||||
|
- Dark gradient background
|
||||||
|
- Displays: title, question ID, difficulty, acceptance rate, tags, description
|
||||||
|
|
||||||
|
### Markdown Document
|
||||||
|
- Clean Markdown formatting
|
||||||
|
- Question metadata in Info section
|
||||||
|
- Full problem description
|
||||||
|
- Example test cases
|
||||||
|
- Link to problem page
|
||||||
|
|
||||||
|
## Supported Platforms
|
||||||
|
|
||||||
|
- **Windows**: Uses `msyh.ttc` (Microsoft YaHei), `simhei.ttf`, etc.
|
||||||
|
- **macOS**: Uses PingFang.ttc, STHeiti Light.ttc
|
||||||
|
- **Linux**: Uses NotoSansCJK, DejaVuSans.ttf, wqy-microhei.ttc
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
LeetCode API (HTML)
|
||||||
|
↓
|
||||||
|
html2text (Markdown)
|
||||||
|
↓
|
||||||
|
Markdown → Plain Text (cleanup)
|
||||||
|
↓
|
||||||
|
Pillow (PNG Image)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/run.py daily.png
|
||||||
|
# Output: daily.png with the daily LeetCode challenge card
|
||||||
|
|
||||||
|
python scripts/run.py daily.md
|
||||||
|
# Output: daily.md with formatted Markdown content
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The script requires internet access to fetch from LeetCode API
|
||||||
|
- Formula rendering is not supported (plain text only)
|
||||||
|
- Chinese fonts must be installed on the system for CJK character display
|
||||||
|
|
@ -0,0 +1,421 @@
|
||||||
|
"""LeetCode Daily Challenge - Fetch & Render as PNG / MD
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
- PNG output: pip install Pillow
|
||||||
|
- Markdown output: pip install html2text
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import urllib.request
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
import html2text
|
||||||
|
|
||||||
|
|
||||||
|
# === GraphQL Query ===
|
||||||
|
GRAPHQL_QUERY = """
|
||||||
|
query questionOfToday {
|
||||||
|
activeDailyCodingChallengeQuestion {
|
||||||
|
date
|
||||||
|
question {
|
||||||
|
questionId
|
||||||
|
title
|
||||||
|
titleSlug
|
||||||
|
difficulty
|
||||||
|
content
|
||||||
|
acRate
|
||||||
|
topicTags {
|
||||||
|
name
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
exampleTestcases
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# === Fetch Data ===
|
||||||
|
def fetch_daily_challenge():
|
||||||
|
url = "https://leetcode.com/graphql/"
|
||||||
|
payload = json.dumps({"query": GRAPHQL_QUERY}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
data = json.loads(resp.read().decode())
|
||||||
|
|
||||||
|
challenge = data.get("data", {}).get("activeDailyCodingChallengeQuestion")
|
||||||
|
if not challenge:
|
||||||
|
raise RuntimeError("No daily challenge found")
|
||||||
|
|
||||||
|
q = challenge["question"]
|
||||||
|
return {
|
||||||
|
"date": challenge["date"],
|
||||||
|
"title": q["title"],
|
||||||
|
"title_slug": q["titleSlug"],
|
||||||
|
"question_id": q["questionId"],
|
||||||
|
"difficulty": q["difficulty"],
|
||||||
|
"content": q.get("content", ""),
|
||||||
|
"tags": [t["name"] for t in (q.get("topicTags") or [])],
|
||||||
|
"example_cases": q.get("exampleTestcases", ""),
|
||||||
|
"ac_rate": q.get("acRate", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Pillow card renderer — zero browser dependency
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _find_font():
|
||||||
|
"""Find a suitable TrueType font across platforms."""
|
||||||
|
candidates = []
|
||||||
|
if sys.platform == "win32":
|
||||||
|
pf = os.environ.get("WINDIR", r"C:\Windows")
|
||||||
|
candidates = [
|
||||||
|
os.path.join(pf, "Fonts", "msyh.ttc"),
|
||||||
|
os.path.join(pf, "Fonts", "msyhbd.ttc"),
|
||||||
|
os.path.join(pf, "Fonts", "simhei.ttf"),
|
||||||
|
os.path.join(pf, "Fonts", "simsun.ttc"),
|
||||||
|
]
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
candidates = [
|
||||||
|
"/System/Library/Fonts/PingFang.ttc",
|
||||||
|
"/System/Library/Fonts/STHeiti Light.ttc",
|
||||||
|
"/Library/Fonts/Arial Unicode.ttf",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
candidates = [
|
||||||
|
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||||
|
]
|
||||||
|
for p in candidates:
|
||||||
|
if os.path.exists(p):
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _html_to_markdown(html):
|
||||||
|
"""Convert LeetCode content HTML to Markdown using html2text."""
|
||||||
|
# 清理所有 Unicode 不可见字符
|
||||||
|
html = re.sub(r'[\u200b-\u200f\u2028-\u202f\ufeff\u00ad]', '', html)
|
||||||
|
html = re.sub(r'[\xa0\u3000]', ' ', html) # non-breaking space, ideographic space
|
||||||
|
html = re.sub(r'[\uff00-\uffef]', '', html) # halfwidth/fullwidth forms
|
||||||
|
|
||||||
|
h = html2text.HTML2Text()
|
||||||
|
h.ignore_links = False
|
||||||
|
h.ignore_images = True
|
||||||
|
h.body_width = 0
|
||||||
|
|
||||||
|
md = h.handle(html)
|
||||||
|
# 清理 markdown 输出中的不可见字符
|
||||||
|
md = re.sub(r'[\u200b-\u200f\u2028-\u202f\ufeff]', '', md)
|
||||||
|
md = re.sub(r'\n{3,}', '\n\n', md)
|
||||||
|
return md.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _markdown_to_text(md):
|
||||||
|
"""Convert Markdown to plain text for Pillow rendering."""
|
||||||
|
if not md:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
text = md
|
||||||
|
|
||||||
|
# 清理不可见 Unicode 字符
|
||||||
|
text = re.sub(r'[\u200b-\u200f\u2028-\u202f\ufeff]', '', text) # zero-width spaces
|
||||||
|
text = re.sub(r'[\xa0]', ' ', text) # non-breaking space
|
||||||
|
|
||||||
|
# 转换 HTML 实体
|
||||||
|
text = text.replace('<', '<')
|
||||||
|
text = text.replace('>', '>')
|
||||||
|
text = text.replace('&', '&')
|
||||||
|
text = text.replace('"', '"')
|
||||||
|
text = text.replace(''', "'")
|
||||||
|
text = text.replace(''', "'")
|
||||||
|
|
||||||
|
# 处理特殊引号
|
||||||
|
text = text.replace('"', '"').replace('"', '"')
|
||||||
|
text = text.replace(''', "'").replace(''', "'")
|
||||||
|
text = text.replace('《', '<').replace('》', '>')
|
||||||
|
|
||||||
|
# 清理 Markdown 格式
|
||||||
|
text = re.sub(r'\*\*(.*?)\*\*', r'\1', text)
|
||||||
|
text = re.sub(r'\*(.*?)*', r'\1', text)
|
||||||
|
text = re.sub(r'__(.*?)__', r'\1', text)
|
||||||
|
text = re.sub(r'_(.*?)_', r'\1', text)
|
||||||
|
text = re.sub(r'`(.*?)`', r'\1', text)
|
||||||
|
text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
|
||||||
|
text = re.sub(r'^[\-\*+]\s+', '- ', text, flags=re.MULTILINE)
|
||||||
|
text = re.sub(r'^\d+\.\s+', '- ', text, flags=re.MULTILINE)
|
||||||
|
text = re.sub(r'\[(.*?)\]\(.*?\)', r'\1', text)
|
||||||
|
text = re.sub(r'!\[(.*?)\]\(.*?\)', r'\1', text)
|
||||||
|
text = re.sub(r'^```.*$', '', text, flags=re.MULTILINE)
|
||||||
|
text = re.sub(r'\n{3,}', '\n\n', text)
|
||||||
|
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_text(text, font, max_width, draw):
|
||||||
|
"""Wrap text to fit within max_width pixels."""
|
||||||
|
lines = []
|
||||||
|
for paragraph in text.split("\n"):
|
||||||
|
if not paragraph.strip():
|
||||||
|
lines.append("")
|
||||||
|
continue
|
||||||
|
current = ""
|
||||||
|
for ch in paragraph:
|
||||||
|
test = current + ch
|
||||||
|
bbox = draw.textbbox((0, 0), test, font=font)
|
||||||
|
if bbox[2] - bbox[0] > max_width and current:
|
||||||
|
lines.append(current)
|
||||||
|
current = ch
|
||||||
|
else:
|
||||||
|
current = test
|
||||||
|
if current:
|
||||||
|
lines.append(current)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def render_image(data, desc_md, img_path):
|
||||||
|
"""Render card as PNG using only Pillow.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: LeetCode question data dict
|
||||||
|
desc_md: Pre-converted markdown from HTML
|
||||||
|
img_path: Output image path
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError("Pillow not installed. Run: pip install Pillow")
|
||||||
|
|
||||||
|
font_path = _find_font()
|
||||||
|
if not font_path:
|
||||||
|
raise RuntimeError("No CJK font found. Install a Chinese font or set WINDIR/font path.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
font_title = ImageFont.truetype(font_path, 26)
|
||||||
|
font_body = ImageFont.truetype(font_path, 16)
|
||||||
|
font_small = ImageFont.truetype(font_path, 14)
|
||||||
|
font_tag = ImageFont.truetype(font_path, 13)
|
||||||
|
font_header = ImageFont.truetype(font_path, 20)
|
||||||
|
font_icon = ImageFont.truetype(font_path, 17)
|
||||||
|
except Exception:
|
||||||
|
font_title = ImageFont.load_default()
|
||||||
|
font_body = font_small = font_tag = font_header = font_icon = font_title
|
||||||
|
|
||||||
|
W = 540
|
||||||
|
PAD = 28
|
||||||
|
RADIUS = 20
|
||||||
|
HEADER_H = 76
|
||||||
|
|
||||||
|
# 将 Markdown 转换为纯文本
|
||||||
|
desc_text = _markdown_to_text(desc_md) if desc_md else ""
|
||||||
|
|
||||||
|
draw_dummy = ImageDraw.Draw(Image.new("RGB", (W, 100)))
|
||||||
|
y = PAD
|
||||||
|
|
||||||
|
title_lines = _wrap_text(data["title"], font_title, W - PAD * 2, draw_dummy)
|
||||||
|
y += len(title_lines) * 34 + 8
|
||||||
|
|
||||||
|
y += 28 + 16
|
||||||
|
|
||||||
|
if data["tags"]:
|
||||||
|
tag_x = PAD
|
||||||
|
tag_row_h = 32
|
||||||
|
for t in data["tags"]:
|
||||||
|
tw = draw_dummy.textlength(t + " ", font=font_tag) + 24
|
||||||
|
if tag_x + tw > W - PAD:
|
||||||
|
y += tag_row_h + 8
|
||||||
|
tag_x = PAD
|
||||||
|
tag_x += tw + 8
|
||||||
|
y += tag_row_h + 22
|
||||||
|
|
||||||
|
if desc_text:
|
||||||
|
desc_lines = _wrap_text(desc_text, font_body, W - PAD * 2, draw_dummy)
|
||||||
|
y += 20
|
||||||
|
y += len(desc_lines) * 24
|
||||||
|
y += 8
|
||||||
|
|
||||||
|
TOTAL_H = y + PAD
|
||||||
|
TOTAL_H = max(TOTAL_H, 400)
|
||||||
|
|
||||||
|
img = Image.new("RGB", (W, TOTAL_H), "#1a1a2e")
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
for row in range(TOTAL_H):
|
||||||
|
r = int(15 + (48 - 15) * row / TOTAL_H)
|
||||||
|
g = int(12 + (43 - 12) * row / TOTAL_H)
|
||||||
|
b = int(41 + (62 - 41) * row / TOTAL_H)
|
||||||
|
draw.line([(0, row), (W, row)], fill=(r, g, b))
|
||||||
|
|
||||||
|
card_top = HEADER_H
|
||||||
|
draw.rounded_rectangle(
|
||||||
|
[0, card_top, W - 1, TOTAL_H - 1],
|
||||||
|
radius=RADIUS,
|
||||||
|
fill="#ffffff",
|
||||||
|
)
|
||||||
|
draw.rectangle([0, card_top, W - 1, card_top + RADIUS], fill="#ffffff")
|
||||||
|
# 蓝色渐变 header
|
||||||
|
for row in range(HEADER_H):
|
||||||
|
ratio = row / HEADER_H
|
||||||
|
r = int(61 - (61 - 42) * ratio) # 61 -> 42
|
||||||
|
g = int(133 - (133 - 98) * ratio) # 133 -> 98
|
||||||
|
b = int(255 - (255 - 239) * ratio) # 255 -> 239
|
||||||
|
draw.line([(0, row), (W, row)], fill=(r, g, b))
|
||||||
|
|
||||||
|
# LC 图标 - 已删除方框
|
||||||
|
|
||||||
|
date_text = data["date"]
|
||||||
|
date_bbox = draw.textbbox((0, 0), date_text, font=font_small)
|
||||||
|
date_w = date_bbox[2] - date_bbox[0]
|
||||||
|
draw.text(
|
||||||
|
(W - PAD - date_w, (HEADER_H - 18) // 2),
|
||||||
|
date_text, fill=(255, 255, 255), font=font_small,
|
||||||
|
)
|
||||||
|
|
||||||
|
cy = card_top + PAD
|
||||||
|
|
||||||
|
diff_styles = {
|
||||||
|
"Easy": {"label": "Easy", "bg": (220, 252, 231), "fg": (22, 101, 52)},
|
||||||
|
"Medium": {"label": "Medium", "bg": (254, 249, 195), "fg": (133, 77, 14)},
|
||||||
|
"Hard": {"label": "Hard", "bg": (254, 202, 202), "fg": (153, 27, 27)},
|
||||||
|
}
|
||||||
|
ds = diff_styles.get(data["difficulty"], {"label": data["difficulty"], "bg": (229, 231, 235), "fg": (55, 65, 81)})
|
||||||
|
|
||||||
|
for line in title_lines:
|
||||||
|
draw.text((PAD, cy), line, fill="#1a1a2e", font=font_title)
|
||||||
|
cy += 34
|
||||||
|
cy += 8
|
||||||
|
|
||||||
|
meta_y = cy
|
||||||
|
id_text = f"#{data['question_id']}"
|
||||||
|
draw.text((PAD, meta_y + 4), id_text, fill="#9ca3af", font=font_small)
|
||||||
|
id_w = draw.textlength(id_text, font=font_small) + 16
|
||||||
|
|
||||||
|
badge_text = ds["label"]
|
||||||
|
badge_bbox = draw.textbbox((0, 0), badge_text, font=font_small)
|
||||||
|
badge_w = badge_bbox[2] - badge_bbox[0] + 24
|
||||||
|
badge_h = 28
|
||||||
|
badge_x = PAD + id_w
|
||||||
|
draw.rounded_rectangle(
|
||||||
|
[badge_x, meta_y, badge_x + badge_w, meta_y + badge_h],
|
||||||
|
radius=14,
|
||||||
|
fill=ds["bg"],
|
||||||
|
)
|
||||||
|
draw.text((badge_x + 12, meta_y + 4), badge_text, fill=ds["fg"], font=font_small)
|
||||||
|
|
||||||
|
rate_text = f"Accept: {data['ac_rate']:.1f}%"
|
||||||
|
rate_x = badge_x + badge_w + 16
|
||||||
|
draw.text((rate_x, meta_y + 4), rate_text, fill="#059669", font=font_small)
|
||||||
|
|
||||||
|
cy = meta_y + badge_h + 20
|
||||||
|
|
||||||
|
if data["tags"]:
|
||||||
|
tag_x = PAD
|
||||||
|
tag_y = cy
|
||||||
|
for t in data["tags"]:
|
||||||
|
tw = draw.textlength(t, font=font_tag) + 24
|
||||||
|
if tag_x + tw > W - PAD:
|
||||||
|
tag_x = PAD
|
||||||
|
tag_y += 32 + 8
|
||||||
|
draw.rounded_rectangle(
|
||||||
|
[tag_x, tag_y, tag_x + tw, tag_y + 30],
|
||||||
|
radius=8,
|
||||||
|
fill=(238, 242, 255),
|
||||||
|
)
|
||||||
|
draw.text((tag_x + 12, tag_y + 6), t, fill=(67, 56, 202), font=font_tag)
|
||||||
|
tag_x += tw + 8
|
||||||
|
cy = tag_y + 38
|
||||||
|
|
||||||
|
if desc_text:
|
||||||
|
draw.text((PAD, cy), "Description", fill="#64748b", font=font_small)
|
||||||
|
cy += 24
|
||||||
|
|
||||||
|
desc_lines = _wrap_text(desc_text, font_body, W - PAD * 2, draw)
|
||||||
|
for line in desc_lines:
|
||||||
|
if line.strip() == "":
|
||||||
|
cy += 8
|
||||||
|
continue
|
||||||
|
draw.text((PAD, cy), line, fill="#374151", font=font_body)
|
||||||
|
cy += 24
|
||||||
|
|
||||||
|
Path(img_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
img.save(img_path, "PNG")
|
||||||
|
|
||||||
|
|
||||||
|
def render_markdown(data, desc_md=""):
|
||||||
|
"""Convert LeetCode content to Markdown format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: LeetCode question data dict
|
||||||
|
desc_md: Pre-converted markdown (optional). If not provided, will be generated from data["content"]
|
||||||
|
"""
|
||||||
|
if not desc_md and data["content"]:
|
||||||
|
desc_md = _html_to_markdown(data["content"])
|
||||||
|
|
||||||
|
diff_map = {
|
||||||
|
"Easy": "Easy",
|
||||||
|
"Medium": "Medium",
|
||||||
|
"Hard": "Hard",
|
||||||
|
}
|
||||||
|
difficulty = diff_map.get(data["difficulty"], data["difficulty"])
|
||||||
|
|
||||||
|
tags_md = ""
|
||||||
|
if data["tags"]:
|
||||||
|
tags_md = "\n".join([f"- `{tag}`" for tag in data["tags"]])
|
||||||
|
|
||||||
|
md = f"""# {data['title']}
|
||||||
|
|
||||||
|
## Info
|
||||||
|
- **#{data['question_id']}** | {difficulty} | Accept Rate: {data['ac_rate']:.1f}%
|
||||||
|
- Date: {data['date']}
|
||||||
|
- Tags:
|
||||||
|
{tags_md}
|
||||||
|
|
||||||
|
## Description
|
||||||
|
{desc_md}
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
```
|
||||||
|
{data['example_cases']}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Link
|
||||||
|
[data](https://leetcode.com/problems/{data['title_slug']}/)
|
||||||
|
"""
|
||||||
|
return md
|
||||||
|
|
||||||
|
|
||||||
|
# === Main ===
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python leetcode_card.py <output.png|output.md>", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
output = sys.argv[1]
|
||||||
|
ext = Path(output).suffix.lower()
|
||||||
|
|
||||||
|
data = fetch_daily_challenge()
|
||||||
|
desc_md = _html_to_markdown(data["content"]) if data["content"] else ""
|
||||||
|
|
||||||
|
if ext == ".md":
|
||||||
|
md = render_markdown(data, desc_md)
|
||||||
|
Path(output).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
Path(output).write_text(md, encoding="utf-8")
|
||||||
|
else:
|
||||||
|
render_image(data, desc_md, output)
|
||||||
|
|
||||||
|
print(json.dumps({"desc": desc_md, "output_file": output}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
---
|
||||||
|
name: markdown-converter
|
||||||
|
description: A versatile Markdown conversion tool that supports converting Markdown to HTML, PNG images, and plain text formats.
|
||||||
|
metadata: {"clawdbot":{"emoji":"📝","os":["linux","darwin","win32"]}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# Markdown Converter
|
||||||
|
|
||||||
|
A versatile Markdown conversion tool that supports converting Markdown to HTML, PNG images, and plain text formats.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **HTML Conversion**: High-quality Markdown to HTML conversion
|
||||||
|
- **PNG Output**: Render Markdown as PNG images
|
||||||
|
- **CJK Support**: Uses system CJK fonts for Chinese character rendering
|
||||||
|
- **Code Highlighting**: Syntax highlighting for code blocks
|
||||||
|
- **Clean Output**: Removes invisible Unicode characters
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install matplotlib html2text pygments markdown
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **matplotlib**: PNG image rendering with excellent CJK support
|
||||||
|
- **html2text**: HTML and Markdown conversion
|
||||||
|
- **Pygments**: Code syntax highlighting
|
||||||
|
- **markdown**: Python Markdown processor
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Convert to HTML
|
||||||
|
python scripts/md_convert.py input.md output.html
|
||||||
|
|
||||||
|
# Convert to PNG
|
||||||
|
python scripts/md_convert.py input.md output.png
|
||||||
|
|
||||||
|
# Convert to plain text
|
||||||
|
python scripts/md_convert.py input.md output.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output Formats
|
||||||
|
|
||||||
|
### HTML
|
||||||
|
- Complete HTML document structure
|
||||||
|
- Inline CSS styling
|
||||||
|
- Code syntax highlighting
|
||||||
|
- Responsive design
|
||||||
|
|
||||||
|
### PNG Card
|
||||||
|
- White card background
|
||||||
|
- Large title font
|
||||||
|
- Automatic text wrapping
|
||||||
|
- CJK character support
|
||||||
|
|
||||||
|
### Plain Text
|
||||||
|
- Plain text output
|
||||||
|
- Preserves basic formatting
|
||||||
|
- Removes invisible characters
|
||||||
|
|
||||||
|
## Supported Platforms
|
||||||
|
|
||||||
|
- **Windows**: Uses system CJK fonts
|
||||||
|
- **macOS**: Uses PingFang and other system fonts
|
||||||
|
- **Linux**: Uses NotoSansCJK and other fonts
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
Markdown Input
|
||||||
|
↓
|
||||||
|
[html2text / markdown library]
|
||||||
|
↓
|
||||||
|
HTML / PNG / Plain Text
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- PNG rendering requires Chinese fonts to be installed on the system
|
||||||
|
- Code highlighting requires Pygments support
|
||||||
|
- Large files may require longer processing time
|
||||||
|
|
@ -0,0 +1,610 @@
|
||||||
|
"""Markdown 转换器 - 支持 HTML / PNG / Plain Text 输出
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
- HTML/PNG 输出: pip install Pillow html2text pygments markdown
|
||||||
|
"""
|
||||||
|
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 字体查找
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _find_font():
|
||||||
|
"""Find a suitable TrueType font across platforms."""
|
||||||
|
candidates = []
|
||||||
|
if sys.platform == "win32":
|
||||||
|
pf = os.environ.get("WINDIR", r"C:\Windows")
|
||||||
|
candidates = [
|
||||||
|
os.path.join(pf, "Fonts", "msyh.ttc"),
|
||||||
|
os.path.join(pf, "Fonts", "msyhbd.ttc"),
|
||||||
|
os.path.join(pf, "Fonts", "simhei.ttf"),
|
||||||
|
os.path.join(pf, "Fonts", "simsun.ttc"),
|
||||||
|
]
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
candidates = [
|
||||||
|
"/System/Library/Fonts/PingFang.ttc",
|
||||||
|
"/System/Library/Fonts/STHeiti Light.ttc",
|
||||||
|
"/Library/Fonts/Arial Unicode.ttf",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
candidates = [
|
||||||
|
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||||
|
]
|
||||||
|
for p in candidates:
|
||||||
|
if os.path.exists(p):
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 工具函数
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _clean_invisible_chars(text):
|
||||||
|
"""清理不可见的 Unicode 字符"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
text = re.sub(r'[\u200b-\u200f\u2028-\u202f\ufeff\u00ad]', '', text)
|
||||||
|
text = re.sub(r'[\xa0\u3000]', ' ', text)
|
||||||
|
text = re.sub(r'[\uff00-\uffef]', '', text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_html_entities(text):
|
||||||
|
"""解码 HTML 实体"""
|
||||||
|
entities = {
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'&': '&',
|
||||||
|
'"': '"',
|
||||||
|
''': "'",
|
||||||
|
''': "'",
|
||||||
|
}
|
||||||
|
for k, v in entities.items():
|
||||||
|
text = text.replace(k, v)
|
||||||
|
# 处理特殊引号
|
||||||
|
text = text.replace('"', '"').replace('"', '"')
|
||||||
|
text = text.replace(''', "'").replace(''', "'")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Markdown 解析
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def parse_markdown(md_text):
|
||||||
|
"""解析 Markdown 文本,提取标题、代码块、段落等元素"""
|
||||||
|
if not md_text:
|
||||||
|
return []
|
||||||
|
|
||||||
|
md_text = _clean_invisible_chars(md_text)
|
||||||
|
lines = md_text.split('\n')
|
||||||
|
elements = []
|
||||||
|
current_paragraph = []
|
||||||
|
|
||||||
|
def flush_paragraph():
|
||||||
|
nonlocal current_paragraph
|
||||||
|
if current_paragraph:
|
||||||
|
text = ' '.join(current_paragraph)
|
||||||
|
if text.strip():
|
||||||
|
elements.append(('paragraph', text.strip()))
|
||||||
|
current_paragraph = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
# 跳过空行
|
||||||
|
if not stripped:
|
||||||
|
flush_paragraph()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 标题
|
||||||
|
header_match = re.match(r'^(#{1,6})\s+(.+)$', stripped)
|
||||||
|
if header_match:
|
||||||
|
flush_paragraph()
|
||||||
|
level = len(header_match.group(1))
|
||||||
|
elements.append(('header', level, header_match.group(2).strip()))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 代码块
|
||||||
|
if stripped.startswith('```'):
|
||||||
|
flush_paragraph()
|
||||||
|
elements.append(('codeblock', stripped[3:].strip()))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 无序列表
|
||||||
|
list_match = re.match(r'^[\-\*+]\s+(.+)$', stripped)
|
||||||
|
if list_match:
|
||||||
|
flush_paragraph()
|
||||||
|
elements.append(('list_item', list_match.group(1).strip()))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 有序列表
|
||||||
|
ordered_match = re.match(r'^\d+\.\s+(.+)$', stripped)
|
||||||
|
if ordered_match:
|
||||||
|
flush_paragraph()
|
||||||
|
elements.append(('ordered_item', ordered_match.group(1).strip()))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 引用
|
||||||
|
if stripped.startswith('>'):
|
||||||
|
flush_paragraph()
|
||||||
|
content = stripped[1:].strip()
|
||||||
|
elements.append(('quote', content))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 水平线
|
||||||
|
if re.match(r'^[\-\*_]{3,}$', stripped):
|
||||||
|
flush_paragraph()
|
||||||
|
elements.append(('hr',))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 链接或图片
|
||||||
|
link_match = re.match(r'!?\[([^\]]+)\]\([^\)]+\)', stripped)
|
||||||
|
if link_match:
|
||||||
|
flush_paragraph()
|
||||||
|
elements.append(('link', link_match.group(1)))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 默认作为段落处理
|
||||||
|
current_paragraph.append(stripped)
|
||||||
|
|
||||||
|
flush_paragraph()
|
||||||
|
return elements
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 文本换行
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _wrap_text(text, font, max_width, draw):
|
||||||
|
"""Wrap text to fit within max_width pixels."""
|
||||||
|
lines = []
|
||||||
|
for paragraph in text.split("\n"):
|
||||||
|
if not paragraph.strip():
|
||||||
|
lines.append("")
|
||||||
|
continue
|
||||||
|
current = ""
|
||||||
|
for ch in paragraph:
|
||||||
|
test = current + ch
|
||||||
|
bbox = draw.textbbox((0, 0), test, font=font)
|
||||||
|
if bbox[2] - bbox[0] > max_width and current:
|
||||||
|
lines.append(current)
|
||||||
|
current = ch
|
||||||
|
else:
|
||||||
|
current = test
|
||||||
|
if current:
|
||||||
|
lines.append(current)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# HTML 转换
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def markdown_to_html(md_text):
|
||||||
|
"""将 Markdown 转换为 HTML"""
|
||||||
|
if not md_text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
md_text = _clean_invisible_chars(md_text)
|
||||||
|
elements = parse_markdown(md_text)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import html
|
||||||
|
except ImportError:
|
||||||
|
import urllib.parse as html
|
||||||
|
|
||||||
|
try:
|
||||||
|
import markdown
|
||||||
|
from markdown.extensions import codehilite
|
||||||
|
|
||||||
|
md = markdown.Markdown(extensions=['codehilite', 'fenced_code', 'tables'])
|
||||||
|
html_content = md.convert(md_text)
|
||||||
|
return _html_template(html_content)
|
||||||
|
except ImportError:
|
||||||
|
# 降级处理:使用简单的转换
|
||||||
|
return _simple_markdown_to_html(md_text)
|
||||||
|
|
||||||
|
|
||||||
|
def _simple_markdown_to_html(md_text):
|
||||||
|
"""简单的 Markdown 到 HTML 转换(无外部依赖)"""
|
||||||
|
lines = md_text.split('\n')
|
||||||
|
html_lines = []
|
||||||
|
|
||||||
|
in_codeblock = False
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
# 代码块开始/结束
|
||||||
|
if stripped.startswith('```'):
|
||||||
|
if in_codeblock:
|
||||||
|
html_lines.append('</code></pre>')
|
||||||
|
in_codeblock = False
|
||||||
|
else:
|
||||||
|
lang = stripped[3:].strip() or ''
|
||||||
|
lang_attr = f' class="language-{lang}"' if lang else ''
|
||||||
|
html_lines.append(f'<pre><code lang="{lang}">')
|
||||||
|
in_codeblock = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_codeblock:
|
||||||
|
html_lines.append(html.escape(line))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 标题
|
||||||
|
header_match = re.match(r'^(#{1,6})\s+(.+)$', stripped)
|
||||||
|
if header_match:
|
||||||
|
level = len(header_match.group(1))
|
||||||
|
content = header_match.group(2)
|
||||||
|
html_lines.append(f'<h{level}>{_decode_html_entities(content)}</h{level}>')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 水平线
|
||||||
|
if re.match(r'^[\-\*_]{3,}$', stripped):
|
||||||
|
html_lines.append('<hr>')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 引用
|
||||||
|
if stripped.startswith('>'):
|
||||||
|
content = stripped[1:].strip()
|
||||||
|
html_lines.append(f'<blockquote>{_decode_html_entities(content)}</blockquote>')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 列表项
|
||||||
|
list_match = re.match(r'^[\-\*+]\s+(.+)$', stripped)
|
||||||
|
if list_match:
|
||||||
|
content = list_match.group(1)
|
||||||
|
html_lines.append(f'<li>{_decode_html_entities(content)}</li>')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 段落
|
||||||
|
if stripped:
|
||||||
|
# 处理粗体和斜体
|
||||||
|
text = _decode_html_entities(stripped)
|
||||||
|
text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text)
|
||||||
|
text = re.sub(r'\*(.+?)\*', r'<em>\1</em>', text)
|
||||||
|
text = re.sub(r'__(.+?)__', r'<strong>\1</strong>', text)
|
||||||
|
text = re.sub(r'_(.+?)_', r'<em>\1</em>', text)
|
||||||
|
text = re.sub(r'`(.+?)`', r'<code>\1</code>', text)
|
||||||
|
html_lines.append(f'<p>{text}</p>')
|
||||||
|
|
||||||
|
return _html_template('\n'.join(html_lines))
|
||||||
|
|
||||||
|
|
||||||
|
def _html_template(content):
|
||||||
|
"""生成完整的 HTML 文档"""
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Markdown Document</title>
|
||||||
|
<style>
|
||||||
|
body {{
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
color: #333;
|
||||||
|
}}
|
||||||
|
h1, h2, h3, h4, h5, h6 {{
|
||||||
|
margin-top: 1.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
font-weight: 600;
|
||||||
|
}}
|
||||||
|
h1 {{ font-size: 2em; border-bottom: 2px solid #333; }}
|
||||||
|
h2 {{ font-size: 1.5em; border-bottom: 1px solid #ddd; }}
|
||||||
|
code {{
|
||||||
|
background: #f4f4f4;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
}}
|
||||||
|
pre {{
|
||||||
|
background: #f4f4f4;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}}
|
||||||
|
pre code {{
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}}
|
||||||
|
blockquote {{
|
||||||
|
border-left: 4px solid #ddd;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
color: #666;
|
||||||
|
}}
|
||||||
|
a {{
|
||||||
|
color: #0066cc;
|
||||||
|
}}
|
||||||
|
hr {{
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
margin: 24px 0;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{content}
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# PNG 渲染 (使用 matplotlib)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _get_matplotlib_font():
|
||||||
|
"""获取支持中文的 matplotlib 字体(通过字体文件路径)"""
|
||||||
|
import matplotlib
|
||||||
|
import matplotlib.font_manager as fm
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Linux 中文字体路径
|
||||||
|
linux_font_paths = [
|
||||||
|
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
|
||||||
|
'/usr/share/fonts/opentype/noto/NotoSansSC-Regular.otf',
|
||||||
|
'/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
|
||||||
|
'/usr/share/fonts/truetype/wqy/wqy-microhei.ttc',
|
||||||
|
'/usr/share/fonts/truetype/wqy/wqy-microhei.ttc',
|
||||||
|
'/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf',
|
||||||
|
'/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc',
|
||||||
|
'/usr/share/fonts/truetype/arphic/uming.ttc',
|
||||||
|
'/usr/share/fonts/truetype/arphic/ukai.ttc',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Windows 中文字体路径
|
||||||
|
if sys.platform == "win32":
|
||||||
|
windir = os.environ.get("WINDIR", r"C:\Windows")
|
||||||
|
windows_font_paths = [
|
||||||
|
os.path.join(windir, "Fonts", "msyh.ttc"),
|
||||||
|
os.path.join(windir, "Fonts", "msyhbd.ttc"),
|
||||||
|
os.path.join(windir, "Fonts", "simhei.ttf"),
|
||||||
|
os.path.join(windir, "Fonts", "simsun.ttc"),
|
||||||
|
os.path.join(windir, "Fonts", "STHeiti Light.ttc"),
|
||||||
|
]
|
||||||
|
linux_font_paths.extend(windows_font_paths)
|
||||||
|
|
||||||
|
# macOS 中文字体路径
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
mac_font_paths = [
|
||||||
|
'/System/Library/Fonts/PingFang.ttc',
|
||||||
|
'/System/Library/Fonts/STHeiti Light.ttc',
|
||||||
|
'/Library/Fonts/Arial Unicode.ttf',
|
||||||
|
'/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
|
||||||
|
]
|
||||||
|
linux_font_paths.extend(mac_font_paths)
|
||||||
|
|
||||||
|
# 查找存在的字体文件
|
||||||
|
for font_path in linux_font_paths:
|
||||||
|
if os.path.exists(font_path):
|
||||||
|
# 清除字体缓存并加载指定字体
|
||||||
|
fm.fontManager.addfont(font_path)
|
||||||
|
font = fm.FontProperties(fname=font_path)
|
||||||
|
# 验证字体可以显示中文
|
||||||
|
return font
|
||||||
|
|
||||||
|
# 如果没找到,返回 None 让 matplotlib 使用默认
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def markdown_to_png(md_text, img_path):
|
||||||
|
"""将 Markdown 渲染为 PNG 图片(使用 matplotlib)"""
|
||||||
|
try:
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import matplotlib.patches as patches
|
||||||
|
import matplotlib.font_manager as fm
|
||||||
|
import matplotlib
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError("matplotlib not installed. Run: pip install matplotlib")
|
||||||
|
|
||||||
|
# 设置非交互式后端
|
||||||
|
matplotlib.use('Agg')
|
||||||
|
|
||||||
|
# 获取中文字体
|
||||||
|
font = _get_matplotlib_font()
|
||||||
|
|
||||||
|
elements = parse_markdown(md_text)
|
||||||
|
|
||||||
|
W, PAD = 10, 0.5 # 英寸, 边距
|
||||||
|
FIG_H = 2.0 # 初始高度
|
||||||
|
LINE_H = 0.35 # 每行高度
|
||||||
|
CODE_H = 0.5 # 代码块初始高度
|
||||||
|
|
||||||
|
# 计算所需高度
|
||||||
|
y = 2.5 # 顶部空间
|
||||||
|
for elem in elements:
|
||||||
|
if elem[0] == 'header':
|
||||||
|
level = elem[1]
|
||||||
|
text = elem[2]
|
||||||
|
chars_per_line = 50 if level <= 2 else 60
|
||||||
|
lines = max(1, len(text) // chars_per_line + 1)
|
||||||
|
y += lines * (0.5 if level <= 2 else 0.4) + 0.2
|
||||||
|
elif elem[0] == 'paragraph':
|
||||||
|
chars_per_line = 60
|
||||||
|
lines = max(1, len(elem[1]) // chars_per_line + 1)
|
||||||
|
y += lines * 0.35 + 0.3
|
||||||
|
elif elem[0] == 'codeblock':
|
||||||
|
lines = elem[1].count('\n') + 2
|
||||||
|
y += lines * 0.3 + 0.2
|
||||||
|
elif elem[0] in ('list_item', 'quote'):
|
||||||
|
chars_per_line = 55
|
||||||
|
lines = max(1, len(elem[1]) // chars_per_line + 1)
|
||||||
|
y += lines * 0.32 + 0.15
|
||||||
|
|
||||||
|
FIG_H = max(8, y)
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(W, FIG_H))
|
||||||
|
fig.patch.set_facecolor('#ffffff')
|
||||||
|
ax.set_facecolor('#ffffff')
|
||||||
|
|
||||||
|
# 标题栏背景
|
||||||
|
header = patches.Rectangle((0, FIG_H - 1.2), W, 1.0, linewidth=0, facecolor='#2d3748')
|
||||||
|
ax.add_patch(header)
|
||||||
|
|
||||||
|
# 标题
|
||||||
|
ax.text(0.5, FIG_H - 0.6, 'Markdown Document',
|
||||||
|
fontsize=18, fontweight='bold', color='white',
|
||||||
|
fontproperties=font, ha='left', va='center')
|
||||||
|
|
||||||
|
ax.text(0.5, FIG_H - 1.0, 'Converted from Markdown',
|
||||||
|
fontsize=10, color='#888888', fontproperties=font, ha='left', va='center')
|
||||||
|
|
||||||
|
ax.set_xlim(0, W)
|
||||||
|
ax.set_ylim(0, FIG_H)
|
||||||
|
ax.axis('off')
|
||||||
|
|
||||||
|
cy = FIG_H - 1.5
|
||||||
|
|
||||||
|
for elem in elements:
|
||||||
|
if elem[0] == 'header':
|
||||||
|
level = elem[1]
|
||||||
|
text = elem[2]
|
||||||
|
size = 16 if level <= 2 else 14
|
||||||
|
weight = 'bold' if level == 1 else 'normal'
|
||||||
|
color = '#1a1a2e' if level == 1 else '#2d3748'
|
||||||
|
ax.text(PAD, cy, text, fontsize=size, fontweight=weight,
|
||||||
|
color=color, fontproperties=font, ha='left', va='top')
|
||||||
|
cy -= size * 0.04 + 0.15
|
||||||
|
|
||||||
|
elif elem[0] == 'paragraph':
|
||||||
|
ax.text(PAD, cy, elem[1], fontsize=11, color='#374151',
|
||||||
|
fontproperties=font, ha='left', va='top', wrap=True)
|
||||||
|
lines = max(1, len(elem[1]) // 60 + 1)
|
||||||
|
cy -= lines * 0.35 + 0.25
|
||||||
|
|
||||||
|
elif elem[0] == 'codeblock':
|
||||||
|
code_h = max(0.5, (elem[1].count('\n') + 2) * 0.3)
|
||||||
|
code_box = patches.Rectangle((PAD, cy - code_h), W - PAD * 2, code_h,
|
||||||
|
linewidth=1, edgecolor='#e0e0e0', facecolor='#f4f4f4')
|
||||||
|
ax.add_patch(code_box)
|
||||||
|
ax.text(PAD + 0.1, cy - 0.15, elem[1][:500], fontsize=9,
|
||||||
|
color='#333333', fontfamily='monospace', va='top')
|
||||||
|
cy -= code_h + 0.2
|
||||||
|
|
||||||
|
elif elem[0] == 'list_item':
|
||||||
|
ax.text(PAD, cy, f'\u2022 {elem[1]}', fontsize=11, color='#374151',
|
||||||
|
fontproperties=font, ha='left', va='top')
|
||||||
|
cy -= 0.35
|
||||||
|
|
||||||
|
elif elem[0] == 'quote':
|
||||||
|
ax.plot([PAD, PAD, PAD + 0.05, PAD + 0.05],
|
||||||
|
[cy, cy - 0.4, cy - 0.4, cy - 0.6],
|
||||||
|
color='#0066cc', linewidth=2)
|
||||||
|
ax.text(PAD + 0.15, cy - 0.1, elem[1], fontsize=11, color='#666666',
|
||||||
|
fontproperties=font, ha='left', va='top')
|
||||||
|
cy -= 0.5
|
||||||
|
|
||||||
|
elif elem[0] == 'hr':
|
||||||
|
ax.axhline(y=cy, color='#e0e0e0', linewidth=1, xmin=0.05, xmax=0.95)
|
||||||
|
cy -= 0.3
|
||||||
|
|
||||||
|
plt.tight_layout(pad=0)
|
||||||
|
|
||||||
|
Path(img_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
plt.savefig(img_path, format='png', dpi=150, bbox_inches='tight',
|
||||||
|
facecolor='#ffffff', edgecolor='none')
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 纯文本转换
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def markdown_to_text(md_text):
|
||||||
|
"""将 Markdown 转换为纯文本"""
|
||||||
|
if not md_text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
md_text = _clean_invisible_chars(md_text)
|
||||||
|
elements = parse_markdown(md_text)
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
for elem in elements:
|
||||||
|
if elem[0] == 'header':
|
||||||
|
level = elem[1]
|
||||||
|
prefix = "#" * level + " "
|
||||||
|
lines.append(f"{prefix}{elem[2]}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
elif elem[0] == 'paragraph':
|
||||||
|
lines.append(elem[1])
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
elif elem[0] == 'codeblock':
|
||||||
|
lines.append("```")
|
||||||
|
lines.append(elem[1])
|
||||||
|
lines.append("```")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
elif elem[0] == 'list_item':
|
||||||
|
lines.append(f"• {elem[1]}")
|
||||||
|
|
||||||
|
elif elem[0] == 'ordered_item':
|
||||||
|
lines.append(f" {elem[1]}")
|
||||||
|
|
||||||
|
elif elem[0] == 'quote':
|
||||||
|
lines.append(f"> {elem[1]}")
|
||||||
|
|
||||||
|
elif elem[0] == 'hr':
|
||||||
|
lines.append("─" * 50)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 主函数
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("Usage: python md_convert.py <input.md> <output.{html|png|txt}>", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
input_path = sys.argv[1]
|
||||||
|
output_path = sys.argv[2]
|
||||||
|
|
||||||
|
if not os.path.exists(input_path):
|
||||||
|
print(f"Error: Input file not found: {input_path}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
md_text = Path(input_path).read_text(encoding="utf-8")
|
||||||
|
ext = Path(output_path).suffix.lower()
|
||||||
|
|
||||||
|
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if ext == ".html":
|
||||||
|
html_content = markdown_to_html(md_text)
|
||||||
|
Path(output_path).write_text(html_content, encoding="utf-8")
|
||||||
|
elif ext == ".png":
|
||||||
|
markdown_to_png(md_text, output_path)
|
||||||
|
elif ext == ".txt":
|
||||||
|
text_content = markdown_to_text(md_text)
|
||||||
|
Path(output_path).write_text(text_content, encoding="utf-8")
|
||||||
|
else:
|
||||||
|
print(f"Error: Unsupported output format: {ext}", file=sys.stderr)
|
||||||
|
print("Supported formats: .html, .png, .txt", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Converted: {input_path} -> {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Reference in New Issue