feat: 增加skill
This commit is contained in:
parent
9bc28e852c
commit
2723adf2d2
|
|
@ -1,6 +1,7 @@
|
|||
# Ignore everything by default
|
||||
*
|
||||
|
||||
!*/
|
||||
# Whitelist specific files and directories
|
||||
!.gitignore
|
||||
!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