This commit is contained in:
Ubuntu
2026-07-03 16:58:28 +08:00
commit 32b43f85bd
6240 changed files with 1987678 additions and 0 deletions
@@ -0,0 +1,8 @@
# Backend
DEBUG=True
DATABASE_URL=sqlite:///./cms.db
SECRET_KEY=your-secret-key-change-in-production
CORS_ORIGINS=http://localhost:5173,http://localhost:10002
# Frontend
VITE_API_URL=http://localhost:10003
Binary file not shown.
@@ -0,0 +1,763 @@
# Personal Blog - Admin Dashboard UI 设计与实现文档
> **设计风格**: Modern Admin Dashboard
> **版本**: v2.0
> **更新日期**: 2026-04-19
---
## 1. 设计概述
### 1.1 设计理念
Admin Dashboard 采用现代化的 SaaS 管理后台设计,强调:
- **专业感**: 深色侧边栏 + 浅色内容区的高对比度设计
- **效率优先**: 信息密度适中,关键数据一目了然
- **视觉层次**: 清晰的视觉层级引导用户操作
- **一致性**: 所有组件遵循统一的设计语言
### 1.2 与访客端的区别
| 维度 | Admin Dashboard | 访客端 (Neobrutalism) |
|------|-----------------|----------------------|
| 用户 | 管理员 | 普通访客 |
| 目标 | 高效管理内容 | 阅读体验 |
| 风格 | 专业、现代 | 个性、艺术 |
| 色调 | 深墨绿 + 翠绿 | 奶油色 + 粉红 |
---
## 2. 色彩系统
### 2.1 CSS 变量定义
```css
:root {
/* 侧边栏 */
--admin-sidebar-bg: #0F172A;
--admin-sidebar-hover: #1E293B;
--admin-sidebar-active: #334155;
--admin-sidebar-text: #94A3B8;
--admin-sidebar-text-active: #FFFFFF;
/* 主内容区 */
--admin-bg: #F8FAFC;
--admin-card: #FFFFFF;
--admin-border: #E2E8F0;
/* 主色调 - 翠绿 */
--admin-primary: #10B981;
--admin-primary-hover: #059669;
--admin-primary-light: #D1FAE5;
--admin-primary-subtle: #ECFDF5;
/* 状态色 */
--admin-success: #10B981;
--admin-warning: #F59E0B;
--admin-danger: #EF4444;
--admin-info: #3B82F6;
/* 文字 */
--admin-text-primary: #0F172A;
--admin-text-secondary: #64748B;
--admin-text-muted: #94A3B8;
--admin-text-inverse: #FFFFFF;
/* 阴影 */
--admin-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--admin-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
--admin-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--admin-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
/* 圆角 */
--admin-radius-sm: 6px;
--admin-radius: 8px;
--admin-radius-lg: 12px;
--admin-radius-xl: 16px;
}
```
### 2.2 Tailwind 配置扩展
```javascript
// tailwind.config.js
theme: {
extend: {
colors: {
admin: {
sidebar: {
DEFAULT: '#0F172A',
hover: '#1E293B',
active: '#334155',
text: '#94A3B8',
'text-active': '#FFFFFF',
},
bg: '#F8FAFC',
card: '#FFFFFF',
border: '#E2E8F0',
primary: {
DEFAULT: '#10B981',
hover: '#059669',
light: '#D1FAE5',
subtle: '#ECFDF5',
},
text: {
primary: '#0F172A',
secondary: '#64748B',
muted: '#94A3B8',
}
}
},
boxShadow: {
'admin': '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
'admin-md': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
'admin-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
}
}
}
```
---
## 3. 布局架构
### 3.1 整体结构
```
┌─────────────────────────────────────────────────────────────┐
│ ┌────────────┐ ┌─────────────────────────────────────────┐ │
│ │ │ │ Header: Logo + Search + Actions │ │
│ │ Sidebar │ ├─────────────────────────────────────────┤ │
│ │ (240px) │ │ │ │
│ │ │ │ Breadcrumbs (可选) │ │
│ │ 🏠 Dashboard│ │ │ │
│ │ 📝 Posts │ │ ┌─────────────────────────────────┐ │ │
│ │ 🏷️ Tags │ │ │ Page Title + Actions │ │ │
│ │ 📁 Categories│ │ └─────────────────────────────────┘ │ │
│ │ 🖼️ Images │ │ │ │
│ │ 💬 Comments│ │ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ │
│ │ ⚙️ Settings│ │ │Stat │ │Stat │ │Stat │ │Stat │ │ │
│ │ │ │ └─────┘ └─────┘ └─────┘ └─────┘ │ │
│ │ ───────────│ │ │ │
│ │ 🔗 External│ │ ┌─────────────────────────────────┐ │ │
│ │ 📊 Analytics│ │ │ Main Content │ │ │
│ │ 🛒 Shop │ │ │ │ │ │
│ └────────────┘ │ └─────────────────────────────────┘ │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### 3.2 布局组件
```jsx
// AdminLayout.jsx
const AdminLayout = ({ children }) => {
return (
<div className="min-h-screen bg-admin-bg flex">
{/* 侧边栏 */}
<Sidebar className="w-60 bg-admin-sidebar fixed h-full" />
{/* 主内容区 */}
<main className="flex-1 ml-60 min-h-screen">
<Header className="sticky top-0 z-30 bg-white border-b border-admin-border" />
<div className="p-6">
{children}
</div>
</main>
</div>
)
}
```
---
## 4. 组件实现规范
### 4.1 侧边栏 (Sidebar)
```jsx
const Sidebar = () => {
const menuGroups = [
{
title: '内容管理',
items: [
{ icon: LayoutDashboard, label: 'Dashboard', path: '/admin' },
{ icon: FileText, label: '文章管理', path: '/admin/posts' },
{ icon: FolderOpen, label: '分类管理', path: '/admin/categories' },
{ icon: Tags, label: '标签管理', path: '/admin/tags' },
{ icon: Image, label: '图片管理', path: '/admin/images' },
{ icon: MessageSquare, label: '评论管理', path: '/admin/comments' },
]
},
{
title: '系统设置',
items: [
{ icon: Settings, label: '系统设置', path: '/admin/settings' },
]
}
]
return (
<aside className="w-60 bg-admin-sidebar min-h-screen flex flex-col">
{/* Logo */}
<div className="h-16 flex items-center px-6 border-b border-gray-800">
<span className="text-xl font-bold text-white">Blog Admin</span>
</div>
{/* Menu */}
<nav className="flex-1 py-4 px-3 space-y-6 overflow-y-auto">
{menuGroups.map((group, idx) => (
<div key={idx}>
<h3 className="px-3 text-xs font-semibold text-admin-sidebar-text uppercase tracking-wider mb-2">
{group.title}
</h3>
<ul className="space-y-1">
{group.items.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) => `
flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
transition-colors duration-200
${isActive
? 'bg-admin-sidebar-active text-white'
: 'text-admin-sidebar-text hover:bg-admin-sidebar-hover hover:text-white'
}
`}
>
<item.icon className="w-5 h-5" />
{item.label}
</NavLink>
))}
</ul>
</div>
))}
</nav>
{/* User */}
<div className="p-4 border-t border-gray-800">
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-admin-sidebar-hover">
<div className="w-8 h-8 rounded-full bg-admin-primary flex items-center justify-center text-white font-medium">
A
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">Admin</p>
<p className="text-xs text-admin-sidebar-text truncate">admin@blog.com</p>
</div>
<LogOut className="w-4 h-4 text-admin-sidebar-text hover:text-white cursor-pointer" />
</div>
</div>
</aside>
)
}
```
### 4.2 头部 (Header)
```jsx
const Header = () => {
return (
<header className="h-16 bg-white border-b border-admin-border flex items-center justify-between px-6">
{/* Search */}
<div className="flex-1 max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-admin-text-muted" />
<input
type="text"
placeholder="搜索文章、评论..."
className="w-full pl-10 pr-4 py-2 bg-gray-50 border border-transparent rounded-lg text-sm
placeholder:text-admin-text-muted
focus:bg-white focus:border-admin-primary/30 focus:ring-2 focus:ring-admin-primary/20
transition-all duration-200"
/>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-3">
<button className="relative p-2 text-admin-text-secondary hover:text-admin-text-primary hover:bg-gray-100 rounded-lg transition-colors">
<Bell className="w-5 h-5" />
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-admin-danger rounded-full"></span>
</button>
<button className="p-2 text-admin-text-secondary hover:text-admin-text-primary hover:bg-gray-100 rounded-lg transition-colors">
<HelpCircle className="w-5 h-5" />
</button>
<div className="h-6 w-px bg-admin-border mx-1"></div>
<a href="/" target="_blank" className="flex items-center gap-2 px-3 py-1.5 text-sm text-admin-primary hover:bg-admin-primary-subtle rounded-lg transition-colors">
<ExternalLink className="w-4 h-4" />
查看网站
</a>
</div>
</header>
)
}
```
### 4.3 统计卡片 (StatsCard)
```jsx
const StatsCard = ({ title, value, change, changeType, icon: Icon, color = 'primary' }) => {
const colorMap = {
primary: 'bg-admin-primary-light text-admin-primary',
success: 'bg-green-100 text-green-600',
warning: 'bg-amber-100 text-amber-600',
danger: 'bg-red-100 text-red-600',
info: 'bg-blue-100 text-blue-600',
}
return (
<div className="bg-white rounded-xl p-6 border border-admin-border shadow-admin hover:shadow-admin-md transition-shadow duration-200">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium text-admin-text-secondary">{title}</p>
<p className="text-2xl font-bold text-admin-text-primary mt-2">{value}</p>
{change && (
<div className="flex items-center mt-2 text-sm">
<span className={`flex items-center font-medium ${
changeType === 'up' ? 'text-admin-success' : 'text-admin-danger'
}`}>
{changeType === 'up' ? <TrendingUp className="w-4 h-4 mr-1" /> : <TrendingDown className="w-4 h-4 mr-1" />}
{change}
</span>
<span className="text-admin-text-muted ml-2">较上月</span>
</div>
)}
</div>
<div className={`p-3 rounded-xl ${colorMap[color]}`}>
<Icon className="w-6 h-6" />
</div>
</div>
</div>
)
}
```
### 4.4 数据表格 (DataTable)
```jsx
const DataTable = ({ columns, data, onEdit, onDelete }) => {
return (
<div className="bg-white rounded-xl border border-admin-border shadow-admin overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-admin-border">
<tr>
{columns.map((col) => (
<th key={col.key} className="px-6 py-4 text-left text-xs font-semibold text-admin-text-secondary uppercase tracking-wider">
{col.title}
</th>
))}
<th className="px-6 py-4 text-right text-xs font-semibold text-admin-text-secondary uppercase tracking-wider">
操作
</th>
</tr>
</thead>
<tbody className="divide-y divide-admin-border">
{data.map((row, idx) => (
<tr key={idx} className="hover:bg-gray-50 transition-colors">
{columns.map((col) => (
<td key={col.key} className="px-6 py-4">
{col.render ? col.render(row[col.key], row) : row[col.key]}
</td>
))}
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => onEdit(row)}
className="p-2 text-admin-text-muted hover:text-admin-primary hover:bg-admin-primary-subtle rounded-lg transition-colors"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => onDelete(row)}
className="p-2 text-admin-text-muted hover:text-admin-danger hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
```
### 4.5 按钮组件 (Button)
```jsx
const Button = ({
children,
variant = 'primary',
size = 'md',
loading = false,
disabled = false,
...props
}) => {
const variants = {
primary: 'bg-admin-primary text-white hover:bg-admin-primary-hover focus:ring-admin-primary',
secondary: 'bg-white text-admin-text-primary border border-admin-border hover:bg-gray-50 focus:ring-gray-200',
ghost: 'bg-transparent text-admin-text-secondary hover:bg-gray-100 hover:text-admin-text-primary',
danger: 'bg-admin-danger text-white hover:bg-red-600 focus:ring-red-500',
}
const sizes = {
sm: 'px-3 py-1.5 text-xs rounded-md',
md: 'px-4 py-2 text-sm rounded-lg',
lg: 'px-6 py-2.5 text-base rounded-lg',
}
return (
<button
disabled={disabled || loading}
className={`
inline-flex items-center justify-center gap-2 font-medium
transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed
${variants[variant]}
${sizes[size]}
`}
{...props}
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{children}
</button>
)
}
```
### 4.6 表单组件
```jsx
// Input
const Input = ({ label, error, ...props }) => {
return (
<div className="space-y-1.5">
{label && <label className="block text-sm font-medium text-admin-text-secondary">{label}</label>}
<input
className={`
w-full px-4 py-2.5 rounded-lg border bg-white text-admin-text-primary
placeholder:text-admin-text-muted
focus:outline-none focus:ring-2 focus:ring-admin-primary/20 focus:border-admin-primary
transition-all duration-200
${error ? 'border-admin-danger focus:border-admin-danger focus:ring-red-200' : 'border-admin-border'}
`}
{...props}
/>
{error && <p className="text-sm text-admin-danger">{error}</p>}
</div>
)
}
// Select
const Select = ({ label, options, ...props }) => {
return (
<div className="space-y-1.5">
{label && <label className="block text-sm font-medium text-admin-text-secondary">{label}</label>}
<div className="relative">
<select
className="w-full px-4 py-2.5 rounded-lg border border-admin-border bg-white text-admin-text-primary
appearance-none focus:outline-none focus:ring-2 focus:ring-admin-primary/20 focus:border-admin-primary
transition-all duration-200"
{...props}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-admin-text-muted pointer-events-none" />
</div>
</div>
)
}
// Textarea
const Textarea = ({ label, error, rows = 4, ...props }) => {
return (
<div className="space-y-1.5">
{label && <label className="block text-sm font-medium text-admin-text-secondary">{label}</label>}
<textarea
rows={rows}
className={`
w-full px-4 py-2.5 rounded-lg border bg-white text-admin-text-primary
placeholder:text-admin-text-muted resize-vertical
focus:outline-none focus:ring-2 focus:ring-admin-primary/20 focus:border-admin-primary
transition-all duration-200
${error ? 'border-admin-danger focus:border-admin-danger focus:ring-red-200' : 'border-admin-border'}
`}
{...props}
/>
{error && <p className="text-sm text-admin-danger">{error}</p>}
</div>
)
}
```
### 4.7 卡片组件 (Card)
```jsx
const Card = ({ children, className = '' }) => {
return (
<div className={`bg-white rounded-xl border border-admin-border shadow-admin ${className}`}>
{children}
</div>
)
}
const CardHeader = ({ title, description, action }) => {
return (
<div className="px-6 py-4 border-b border-admin-border flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-admin-text-primary">{title}</h3>
{description && <p className="text-sm text-admin-text-secondary mt-1">{description}</p>}
</div>
{action && <div>{action}</div>}
</div>
)
}
const CardContent = ({ children, className = '' }) => {
return <div className={`px-6 py-4 ${className}`}>{children}</div>
}
const CardFooter = ({ children, className = '' }) => {
return (
<div className={`px-6 py-4 border-t border-admin-border bg-gray-50 rounded-b-xl ${className}`}>
{children}
</div>
)
}
```
### 4.8 模态框 (Modal)
```jsx
const Modal = ({ isOpen, onClose, title, children, footer }) => {
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-xl shadow-admin-lg w-full max-w-lg mx-4 animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between px-6 py-4 border-b border-admin-border">
<h3 className="text-lg font-semibold text-admin-text-primary">{title}</h3>
<button onClick={onClose} className="p-1 text-admin-text-muted hover:text-admin-text-primary rounded-lg hover:bg-gray-100 transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4">
{children}
</div>
{footer && (
<div className="px-6 py-4 border-t border-admin-border bg-gray-50 rounded-b-xl flex justify-end gap-3">
{footer}
</div>
)}
</div>
</div>
)
}
```
### 4.9 Toast 通知
```jsx
const Toast = ({ message, type = 'success', onClose }) => {
const icons = {
success: CheckCircle,
error: XCircle,
warning: AlertTriangle,
info: Info,
}
const colors = {
success: 'bg-green-50 border-green-200 text-green-800',
error: 'bg-red-50 border-red-200 text-red-800',
warning: 'bg-amber-50 border-amber-200 text-amber-800',
info: 'bg-blue-50 border-blue-200 text-blue-800',
}
const Icon = icons[type]
useEffect(() => {
const timer = setTimeout(onClose, 3000)
return () => clearTimeout(timer)
}, [onClose])
return (
<div className={`fixed top-4 right-4 z-50 flex items-center gap-3 px-4 py-3 rounded-lg border shadow-admin-lg animate-in slide-in-from-right ${colors[type]}`}>
<Icon className="w-5 h-5" />
<span className="text-sm font-medium">{message}</span>
<button onClick={onClose} className="ml-2 hover:opacity-70">
<X className="w-4 h-4" />
</button>
</div>
)
}
```
### 4.10 分页组件 (Pagination)
```jsx
const Pagination = ({ currentPage, totalPages, onPageChange }) => {
return (
<div className="flex items-center justify-between px-4 py-3 bg-white border-t border-admin-border">
<div className="text-sm text-admin-text-secondary">
第 <span className="font-medium text-admin-text-primary">{currentPage}</span> 页,共 <span className="font-medium">{totalPages}</span> 页
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="p-2 rounded-lg border border-admin-border text-admin-text-secondary hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const page = i + 1
return (
<button
key={page}
onClick={() => onPageChange(page)}
className={`w-9 h-9 rounded-lg text-sm font-medium transition-colors ${
currentPage === page
? 'bg-admin-primary text-white'
: 'text-admin-text-secondary hover:bg-gray-50'
}`}
>
{page}
</button>
)
})}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="p-2 rounded-lg border border-admin-border text-admin-text-secondary hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)
}
```
---
## 5. 页面实现
### 5.1 Dashboard 页面
```jsx
const Dashboard = () => {
const stats = [
{ title: '总文章数', value: '128', change: '12%', changeType: 'up', icon: FileText },
{ title: '本月发布', value: '24', change: '8%', changeType: 'up', icon: TrendingUp },
{ title: '总浏览量', value: '45.2K', change: '23%', changeType: 'up', icon: Eye },
{ title: '待审评论', value: '18', change: '5%', changeType: 'down', icon: MessageSquare },
]
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-admin-text-primary">Dashboard</h1>
<p className="text-admin-text-secondary mt-1">欢迎回来,今天也是创作的好日子!</p>
</div>
<Button>
<Plus className="w-4 h-4" />
写文章
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{stats.map((stat) => (
<StatsCard key={stat.title} {...stat} />
))}
</div>
{/* Recent Posts */}
<Card>
<CardHeader
title="最近文章"
description="最近发布的 5 篇文章"
action={<Button variant="ghost" size="sm">查看全部 →</Button>}
/>
<CardContent>
<DataTable
columns={[
{ key: 'title', title: '标题' },
{ key: 'category', title: '分类' },
{ key: 'views', title: '浏览量' },
{ key: 'date', title: '发布时间' },
]}
data={recentPosts}
/>
</CardContent>
</Card>
</div>
)
}
```
---
## 6. 文件结构
```
frontend/src/
├── components/
│ └── admin/
│ ├── AdminLayout.jsx # 后台布局
│ ├── Sidebar.jsx # 侧边栏
│ ├── Header.jsx # 顶部导航
│ ├── StatsCard.jsx # 统计卡片
│ ├── DataTable.jsx # 数据表格
│ ├── Button.jsx # 按钮
│ ├── Card.jsx # 卡片组件
│ ├── Input.jsx # 输入框
│ ├── Select.jsx # 下拉选择
│ ├── Modal.jsx # 模态框
│ ├── Toast.jsx # 通知提示
│ └── Pagination.jsx # 分页
├── pages/
│ └── admin/
│ ├── Dashboard.jsx
│ ├── Posts.jsx
│ ├── PostEdit.jsx
│ ├── Categories.jsx
│ ├── Tags.jsx
│ ├── Images.jsx
│ ├── Comments.jsx
│ └── Settings.jsx
├── styles/
│ └── admin.css # Admin 主题样式
└── hooks/
└── useToast.js # Toast 状态管理
```
---
## 7. 更新日志
| 日期 | 版本 | 变更内容 |
|------|------|----------|
| 2026-04-19 | v2.0 | 初始版本,完整的 Admin Dashboard 设计规范 |
---
*本文档定义了个人博客后台管理的完整 UI 设计规范与组件实现。*
+69
View File
@@ -0,0 +1,69 @@
# 飞点健康 CMS
官网内容管理系统 - 首页模块可配置。
## 技术栈
- **后端**: FastAPI + SQLAlchemy + SQLite
- **前端**: React + Vite + TailwindCSS
- **风格**: 新粗野主义 (Neobrutalism)
## 快速开始
### 1. 启动后端
```bash
cd backend
source .venv/bin/activate
uvicorn app.main:app --host 0.0.0.0 --port 10003 --reload
```
### 2. 启动前端
```bash
cd frontend
npm run dev
```
### 3. 访问
- 前台: http://localhost:10002
- 后台: http://localhost:10002/admin
- API文档: http://localhost:10003/docs
## 默认账号
- 用户名: `admin`
- 密码: `admin123`
## 功能
- [x] JWT 认证
- [x] 首页模块管理(6个预设模块)
- [x] 导航菜单管理
- [x] 系统设置管理
- [x] 图片上传
## 项目结构
```
website-new/
├── backend/
│ ├── app/
│ │ ├── models/ # 数据模型
│ │ ├── routers/ # API路由
│ │ ├── auth.py # 认证逻辑
│ │ ├── config.py # 配置
│ │ ├── database.py # 数据库
│ │ └── main.py # 应用入口
│ ├── .venv/
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── api/ # API客户端
│ │ ├── components/ # 组件
│ │ ├── contexts/ # React Context
│ │ └── pages/ # 页面
│ └── package.json
└── scripts/ # 启动脚本
```
@@ -0,0 +1,769 @@
# Feidian Health - UI 设计规范
> **文档类型**: 设计系统 (Design System)
> **适用范围**: Admin Dashboard 后台管理
> **版本**: v1.0
> **更新日期**: 2026-04-19
---
## 目录
1. [设计原则](#1-设计原则)
2. [色彩系统](#2-色彩系统)
3. [排版系统](#3-排版系统)
4. [间距系统](#4-间距系统)
5. [阴影与圆角](#5-阴影与圆角)
6. [组件规范](#6-组件规范)
7. [布局规范](#7-布局规范)
8. [动效规范](#8-动效规范)
9. [图标规范](#9-图标规范)
10. [响应式规范](#10-响应式规范)
---
## 1. 设计原则
### 1.1 核心原则
| 原则 | 描述 | 实践方式 |
|------|------|---------|
| **清晰 (Clarity)** | 信息层级明确,用户一眼找到重点 | 足够的对比度、合理的留白 |
| **效率 (Efficiency)** | 减少操作步骤,提高工作流效率 | 快捷操作、批量处理 |
| **一致 (Consistency)** | 相同元素表现一致,降低学习成本 | 统一的组件、颜色、间距 |
| **反馈 (Feedback)** | 操作后有明确的状态反馈 | Toast、Loading、过渡动画 |
### 1.2 设计价值观
- **内容优先**: 界面服务于内容,而非干扰
- **克制美学**: 减少不必要的装饰,保持专业感
- **渐进披露**: 复杂功能分层展示,避免信息过载
---
## 2. 色彩系统
### 2.1 色彩层级
```
┌─────────────────────────────────────────────────────────┐
│ 色彩层级金字塔 │
├─────────────────────────────────────────────────────────┤
│ │
│ ▲ 强调色 │
│ ╱ ╲ 10% (主按钮、链接) │
│ ╱ ╲ │
│ ▲ 主色 │
│ ╱ ╲ 20% (交互元素、状态) │
│ ╱ ╲ │
│ ▲ 文字色 │
│ ╱ ╲ 30% (标题、正文) │
│ ╱ ╲ │
│ ▲ 背景色 │
│ ╱ ╲ 40% (页面、卡片背景) │
│ ╱ ╲ │
│ │
└─────────────────────────────────────────────────────────┘
```
### 2.2 主色板
#### 主色调 (Primary)
| 色值 | 名称 | 用途 |
|------|------|------|
| `#10B981` | Primary | 主按钮、主链接、强调 |
| `#059669` | Primary Hover | 悬停状态 |
| `#D1FAE5` | Primary Light | 图标背景、标签背景 |
| `#ECFDF5` | Primary Subtle | 选中状态背景 |
#### 侧边栏色 (Sidebar)
| 色值 | 名称 | 用途 |
|------|------|------|
| `#0F172A` | Sidebar BG | 侧边栏背景 |
| `#1E293B` | Sidebar Hover | 菜单项悬停 |
| `#334155` | Sidebar Active | 当前选中项 |
| `#94A3B8` | Sidebar Text | 默认文字 |
| `#FFFFFF` | Sidebar Text Active | 选中文字 |
#### 中性色 (Neutral)
| 色值 | 名称 | 用途 |
|------|------|------|
| `#0F172A` | Text Primary | 主要文字、标题 |
| `#64748B` | Text Secondary | 次要文字、描述 |
| `#94A3B8` | Text Muted | 弱化文字、占位符 |
| `#E2E8F0` | Border | 分割线、边框 |
| `#F8FAFC` | BG Main | 主背景 |
| `#FFFFFF` | BG Card | 卡片背景 |
#### 功能色 (Functional)
| 色值 | 名称 | 用途 |
|------|------|------|
| `#10B981` | Success | 成功、增长 |
| `#F59E0B` | Warning | 警告、提示 |
| `#EF4444` | Danger | 错误、删除 |
| `#3B82F6` | Info | 信息、提示 |
### 2.3 色彩使用规则
```
✅ 正确用法:
- 主色仅用于主要操作按钮和重要链接
- 功能色用于状态指示和反馈
- 文字色保持 3 个层级以内
❌ 错误用法:
- 同一页面使用超过 3 种强调色
- 正文使用纯黑 (#000000)
- 边框颜色过深,喧宾夺主
```
### 2.4 对比度要求
| 场景 | 最小对比度 | 推荐对比度 |
|------|-----------|-----------|
| 正文文字 | 4.5:1 | 7:1 |
| 大号文字 | 3:1 | 4.5:1 |
| 图标 | 3:1 | 4.5:1 |
| 按钮 | 3:1 | 4.5:1 |
---
## 3. 排版系统
### 3.1 字体族
```css
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
```
- **主要**: Inter (现代、清晰、专业)
- **后备**: 系统字体栈
### 3.2 字号规范
| 名称 | 类名 | 尺寸 | 行高 | 字重 | 用途 |
|------|------|------|------|------|------|
| Display | `text-3xl` | 30px | 36px | 700 | 大数字展示 |
| H1 | `text-2xl` | 24px | 32px | 600 | 页面标题 |
| H2 | `text-xl` | 20px | 28px | 600 | 区块标题 |
| H3 | `text-lg` | 18px | 28px | 500 | 卡片标题 |
| Body | `text-base` | 16px | 24px | 400 | 正文 |
| Body Sm | `text-sm` | 14px | 20px | 400 | 次要正文 |
| Caption | `text-xs` | 12px | 16px | 500 | 标签、辅助 |
### 3.3 字重使用
| 字重 | 数值 | 用途 |
|------|------|------|
| Regular | 400 | 正文、描述 |
| Medium | 500 | 按钮、标签、导航 |
| Semibold | 600 | 标题、重要文字 |
| Bold | 700 | 大数字、强调 |
### 3.4 排版规则
```
标题规范:
- 页面标题: text-2xl font-semibold
- 卡片标题: text-lg font-semibold
- 表单标签: text-sm font-medium
正文规范:
- 主要文字: text-base text-admin-text-primary
- 次要文字: text-sm text-admin-text-secondary
- 辅助文字: text-xs text-admin-text-muted
行高规则:
- 标题: 行高 = 1.25 × 字号
- 正文: 行高 = 1.5 × 字号
- 紧凑: 行高 = 1.25 × 字号
```
---
## 4. 间距系统
### 4.1 间距 Token
| Token | 值 | 用途 |
|-------|-----|------|
| `space-1` | 4px | 图标与文字间距 |
| `space-2` | 8px | 紧凑内边距、小间隙 |
| `space-3` | 12px | 按钮内边距、列表项间距 |
| `space-4` | 16px | 标准内边距、卡片间距 |
| `space-6` | 24px | 区块间距、大内边距 |
| `space-8` | 32px | 页面级间距 |
| `space-10` | 40px | 大区块分隔 |
| `space-12` | 48px | 章节间距 |
### 4.2 组件间距
| 组件 | 内边距 | 外边距 |
|------|--------|--------|
| 按钮 (sm) | px-3 py-1.5 | - |
| 按钮 (md) | px-4 py-2 | - |
| 按钮 (lg) | px-6 py-2.5 | - |
| 卡片 | p-6 | mb-6 |
| 卡片头部 | px-6 py-4 | - |
| 卡片内容 | px-6 py-4 | - |
| 表单输入框 | px-4 py-2.5 | mb-4 |
| 表格行 | px-6 py-4 | - |
### 4.3 布局间距
```
页面布局:
├── 主内容区: p-6 (24px)
├── 区块间距: space-y-6 (24px)
├── 卡片网格: gap-6 (24px)
└── 卡片内部: space-y-4 (16px)
表单布局:
├── 表单区块: space-y-6 (24px)
├── 表单项: space-y-1.5 (6px)
├── 标签与输入: 间距 6px
└── 按钮组: gap-3 (12px)
```
---
## 5. 阴影与圆角
### 5.1 阴影规范
| 名称 | 值 | 用途 |
|------|-----|------|
| `shadow-sm` | `0 1px 2px 0 rgba(0,0,0,0.05)` | 输入框、小按钮 |
| `shadow` | `0 1px 3px 0 rgba(0,0,0,0.1), 0 1px 2px 0 rgba(0,0,0,0.06)` | 标准卡片 |
| `shadow-md` | `0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06)` | 悬停卡片 |
| `shadow-lg` | `0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05)` | 下拉菜单、模态框 |
| `shadow-xl` | `0 20px 25px -5px rgba(0,0,0,0.1), 0 10px 10px -5px rgba(0,0,0,0.04)` | 特殊强调 |
### 5.2 阴影使用场景
```
✅ 使用阴影:
- 卡片组件: shadow (默认), shadow-md (悬停)
- 下拉菜单: shadow-lg
- 模态框: shadow-lg
- Toast 通知: shadow-lg
- 按钮: shadow-sm (主按钮)
❌ 避免阴影:
- 输入框 (使用 border 代替)
- 分割线
- 纯文本元素
```
### 5.3 圆角规范
| Token | 值 | 用途 |
|-------|-----|------|
| `rounded-sm` | 6px | 小按钮、标签 |
| `rounded` | 8px | 标准按钮、输入框、小卡片 |
| `rounded-lg` | 12px | 卡片、模态框 |
| `rounded-xl` | 16px | 大卡片、特殊组件 |
| `rounded-full` | 50% | 头像、圆形按钮 |
### 5.4 圆角使用规则
```
组件圆角:
├── 按钮 (sm): rounded-md (6px)
├── 按钮 (md/lg): rounded-lg (8px)
├── 卡片: rounded-xl (12px)
├── 输入框: rounded-lg (8px)
├── 下拉菜单: rounded-lg (8px)
├── 模态框: rounded-xl (12px)
├── 头像: rounded-full
└── Toast: rounded-lg (8px)
```
---
## 6. 组件规范
### 6.1 按钮 (Button)
#### 变体规范
```
Primary (主按钮):
├── 背景: bg-admin-primary (#10B981)
├── 文字: text-white
├── 悬停: bg-admin-primary-hover (#059669)
├── 阴影: shadow-sm
└── 用途: 主要操作、保存、提交
Secondary (次按钮):
├── 背景: bg-white
├── 边框: border border-admin-border
├── 文字: text-admin-text-primary
├── 悬停: bg-gray-50
└── 用途: 次要操作、取消
Ghost (幽灵按钮):
├── 背景: transparent
├── 文字: text-admin-text-secondary
├── 悬停: bg-gray-100 text-admin-text-primary
└── 用途: 低优先级操作、图标按钮
Danger (危险按钮):
├── 背景: bg-admin-danger (#EF4444)
├── 文字: text-white
├── 悬停: bg-red-600
└── 用途: 删除、危险操作
```
#### 尺寸规范
| 尺寸 | 内边距 | 字号 | 圆角 | 用途 |
|------|--------|------|------|------|
| sm | px-3 py-1.5 | text-xs | rounded-md | 表格内、紧凑布局 |
| md | px-4 py-2 | text-sm | rounded-lg | 标准按钮 |
| lg | px-6 py-2.5 | text-base | rounded-lg | 强调操作 |
#### 状态规范
```
默认状态:
- 正常显示
悬停状态:
- 背景色加深
- 阴影增强
- 过渡时间: 200ms
点击状态:
- 轻微下沉 (transform: translateY(1px))
- 过渡时间: 100ms
禁用状态:
- opacity: 0.5
- cursor: not-allowed
- 无交互效果
加载状态:
- 显示 Loading 图标
- 禁用点击
```
### 6.2 输入框 (Input)
#### 基础样式
```
默认状态:
├── 背景: bg-white
├── 边框: border border-admin-border
├── 圆角: rounded-lg
├── 内边距: px-4 py-2.5
├── 文字: text-admin-text-primary
└── 占位符: placeholder:text-admin-text-muted
聚焦状态:
├── 边框: border-admin-primary
├── 阴影: ring-2 ring-admin-primary/20
└── 过渡: transition-all duration-200
错误状态:
├── 边框: border-admin-danger
├── 阴影: ring-2 ring-red-200
└── 错误文字: text-sm text-admin-danger
```
#### 尺寸规范
| 尺寸 | 内边距 | 高度 | 用途 |
|------|--------|------|------|
| sm | px-3 py-2 | 36px | 紧凑表单 |
| md | px-4 py-2.5 | 42px | 标准输入 |
| lg | px-4 py-3 | 48px | 强调输入 |
### 6.3 卡片 (Card)
#### 结构规范
```
Card:
├── 背景: bg-white
├── 圆角: rounded-xl (12px)
├── 阴影: shadow
├── 边框: border border-admin-border
└── 内边距: 根据内容调整
CardHeader:
├── 下边框: border-b border-admin-border
├── 内边距: px-6 py-4
├── 标题: text-lg font-semibold
└── 描述: text-sm text-admin-text-secondary
CardContent:
└── 内边距: px-6 py-4
CardFooter:
├── 上边框: border-t border-admin-border
├── 背景: bg-gray-50
├── 圆角: rounded-b-xl
└── 内边距: px-6 py-4
```
#### 变体规范
```
标准卡片:
- shadow
- hover:shadow-md (可选)
扁平卡片:
- shadow-none
- bg-gray-50
强调卡片:
- border-admin-primary/30
- bg-admin-primary-subtle
```
### 6.4 表格 (Table)
#### 结构规范
```
Table:
├── 背景: bg-white
├── 圆角: rounded-xl
├── 阴影: shadow
└── overflow: hidden
TableHead:
├── 背景: bg-gray-50
├── 下边框: border-b border-admin-border
└── 文字: text-xs font-semibold uppercase
TableRow:
├── 下边框: border-b border-admin-border
└── 悬停: bg-gray-50
TableCell:
├── 内边距: px-6 py-4
├── 表头: text-admin-text-secondary
└── 内容: text-admin-text-primary
```
#### 行高规范
| 密度 | 内边距 | 用途 |
|------|--------|------|
| 紧凑 | px-4 py-3 | 数据量大时 |
| 标准 | px-6 py-4 | 默认 |
| 宽松 | px-6 py-5 | 强调行 |
### 6.5 标签 (Tag/Badge)
#### 变体规范
```
Primary:
├── 背景: bg-admin-primary-light
├── 文字: text-admin-primary
└── 圆角: rounded-full
Success:
├── 背景: bg-green-100
├── 文字: text-green-700
└── 圆角: rounded-full
Warning:
├── 背景: bg-amber-100
├── 文字: text-amber-700
└── 圆角: rounded-full
Danger:
├── 背景: bg-red-100
├── 文字: text-red-700
└── 圆角: rounded-full
Secondary:
├── 背景: bg-gray-100
├── 文字: text-gray-700
└── 圆角: rounded-full
```
#### 尺寸规范
| 尺寸 | 内边距 | 字号 | 用途 |
|------|--------|------|------|
| sm | px-2 py-0.5 | text-xs | 表格内、紧凑 |
| md | px-2.5 py-1 | text-xs | 默认 |
| lg | px-3 py-1 | text-sm | 强调 |
---
## 7. 布局规范
### 7.1 页面布局
```
┌─────────────────────────────────────────────┐
│ Sidebar (240px) │ Main Content (flex-1) │
│ │ │
│ Logo │ Header (64px) │
│ │ ──────────────────────── │
│ Navigation │ │
│ │ Page Header │
│ ─────────── │ ──────────────────────── │
│ │ │
│ User Profile │ Content Area │
│ │ │
└─────────────────────────────────────────────┘
```
### 7.2 网格系统
#### 统计卡片网格
```
桌面端 (lg+):
- grid-cols-4
- gap-6
平板端 (md):
- grid-cols-2
- gap-6
移动端:
- grid-cols-1
- gap-4
```
#### 内容网格
```
两栏布局:
├── 左侧: w-2/3 (主要内容)
└── 右侧: w-1/3 (侧边栏)
表单布局:
├── 单列: max-w-2xl mx-auto
├── 双列: grid-cols-2 gap-6
└── 三列: grid-cols-3 gap-6
```
### 7.3 内容区域
| 区域 | 最大宽度 | 对齐 | 用途 |
|------|---------|------|------|
| 全宽 | 100% | - | 表格、Dashboard |
| 适中 | max-w-4xl (896px) | mx-auto | 表单页面 |
| 窄 | max-w-2xl (672px) | mx-auto | 设置页面 |
---
## 8. 动效规范
### 8.1 过渡时间
| 时长 | 用途 |
|------|------|
| 100ms | 按钮点击、快速反馈 |
| 150ms | 颜色变化、透明度 |
| 200ms | 阴影、边框、transform |
| 300ms | 页面切换、模态框 |
| 500ms | Toast 进入/退出 |
### 8.2 缓动函数
```css
/* 标准 */
transition-timing-function: ease-in-out;
/* 进入 */
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
/* 退出 */
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
/* 弹性 */
transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
```
### 8.3 常用动效
#### 按钮悬停
```css
transition: all 200ms ease-in-out;
/* 悬停: 背景色变深 + 阴影增强 */
```
#### 卡片悬停
```css
transition: all 200ms ease-in-out;
/* 悬停: shadow → shadow-md + translateY(-2px) */
```
#### Toast 进入
```css
animation: slideIn 300ms cubic-bezier(0, 0, 0.2, 1);
/* 从右侧滑入 + 淡入 */
```
#### 模态框进入
```css
animation: modalIn 200ms cubic-bezier(0, 0, 0.2, 1);
/* 淡入 + 轻微缩放 */
```
### 8.4 性能优化
```
✅ 性能友好:
- transform (位移、缩放)
- opacity (淡入淡出)
- will-change (提前声明)
❌ 避免使用:
- width/height 动画
- top/left 动画
- box-shadow 大范围变化
```
---
## 9. 图标规范
### 9.1 图标库
- **主要**: [Lucide React](https://lucide.dev/)
- **风格**: 线性图标 (outline)
- **线宽**: 2px
- **端点**: 圆角
### 9.2 图标尺寸
| 尺寸 | 值 | 用途 |
|------|-----|------|
| xs | 14px | 内联文字 |
| sm | 16px | 按钮、表单 |
| md | 20px | 导航菜单 |
| lg | 24px | 独立图标 |
| xl | 32px | 空状态、强调 |
### 9.3 图标使用规则
```
图标 + 文字:
├── 间距: gap-2 (8px)
├── 图标位置: 文字左侧 (默认)
└── 对齐: items-center
图标按钮:
├── 尺寸: p-2 (按钮整体)
├── 图标: w-4 h-4 或 w-5 h-5
└── 圆角: rounded-lg
独立图标:
└── 尺寸: w-6 h-6 或更大
```
---
## 10. 响应式规范
### 10.1 断点定义
| 断点 | 宽度 | 设备 |
|------|------|------|
| sm | 640px | 大手机 |
| md | 768px | 平板 |
| lg | 1024px | 小桌面 |
| xl | 1280px | 桌面 |
| 2xl | 1536px | 大桌面 |
### 10.2 响应式规则
#### 侧边栏
```
桌面端 (lg+):
├── 固定显示
├── 宽度: 240px
└── 位置: fixed left-0
平板/移动端 (< lg):
├── 默认隐藏
├── 触发: 汉堡菜单
└── 遮罩: 半透明背景
```
#### 统计卡片
```
桌面端: grid-cols-4
平板端: grid-cols-2
移动端: grid-cols-1
```
#### 表格
```
桌面端: 完整显示
移动端:
├── 横向滚动
└── 或卡片式重构
```
### 10.3 触摸优化
```
最小点击区域:
├── 按钮: 44px × 44px
├── 图标按钮: 40px × 40px
└── 列表项: 48px 高度
间距调整:
├── 移动端增加间距
└── 按钮尺寸增大
```
---
## 11. 设计检查清单
### 11.1 新建组件检查
- [ ] 颜色使用设计系统 token
- [ ] 间距使用标准 spacing
- [ ] 字号符合排版规范
- [ ] 圆角统一为 8px/12px
- [ ] 阴影符合层级规范
- [ ] 包含所有状态样式 (默认/悬停/禁用)
- [ ] 过渡动画 150-200ms
- [ ] 支持响应式适配
### 11.2 代码审查检查
- [ ] 无硬编码颜色值
- [ ] 无硬编码间距值
- [ ] 可访问性达标 (对比度)
- [ ] 键盘导航支持
- [ ] 屏幕阅读器友好
---
## 12. 更新日志
| 日期 | 版本 | 变更内容 |
|------|------|----------|
| 2026-04-19 | v1.0 | 初始版本,完整的 UI 设计规范 |
---
*本文档为 Personal Blog Admin Dashboard 的 UI 设计规范,所有组件和页面应遵循此规范进行设计和开发。*
@@ -0,0 +1,64 @@
import time
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page(viewport={'width': 1400, 'height': 900})
# 1. 打开登录页
page.goto('http://localhost:10002/admin/login')
page.wait_for_load_state('networkidle')
page.screenshot(path='e:/nanocode/website-new/screenshot_01_login.png')
print('Step 1: 打开登录页')
# 2. 填写账号密码
page.fill('input[type=text]', 'admin')
page.fill('input[type=password]', 'abcd1234')
page.screenshot(path='e:/nanocode/website-new/screenshot_02_filled.png')
print('Step 2: 填写账号密码')
# 3. 点击登录
page.click('button[type=submit]')
page.wait_for_url('**/admin')
page.wait_for_load_state('networkidle')
page.screenshot(path='e:/nanocode/website-new/screenshot_03_dashboard.png')
print('Step 3: 登录成功,进入后台')
# 4. 点击"新闻资讯"展开子菜单
page.click('button:has-text("新闻资讯")')
time.sleep(0.5)
page.screenshot(path='e:/nanocode/website-new/screenshot_04_menu_expanded.png')
print('Step 4: 展开新闻资讯菜单')
# 5. 点击"栏目管理"
page.click('a:has-text("栏目管理")')
page.wait_for_url('**/admin/news/categories')
page.wait_for_load_state('networkidle')
page.screenshot(path='e:/nanocode/website-new/screenshot_05_categories.png')
print('Step 5: 进入栏目管理页面')
# 6. 点击"添加栏目"
page.click('button:has-text("添加栏目")')
time.sleep(0.5)
page.screenshot(path='e:/nanocode/website-new/screenshot_06_modal_open.png')
print('Step 6: 打开添加栏目弹窗')
# 7. 填写栏目信息
page.fill('input[placeholder*="如:公司新闻"]', '测试栏目')
page.fill('input[placeholder*="如:company-news"]', 'test-column')
page.fill('textarea[placeholder*="栏目简介"]', '这是一个用于测试的栏目')
page.screenshot(path='e:/nanocode/website-new/screenshot_07_form_filled.png')
print('Step 7: 填写栏目信息')
# 8. 点击创建
page.click('button:has-text("创建")')
time.sleep(1)
page.screenshot(path='e:/nanocode/website-new/screenshot_08_created.png')
print('Step 8: 创建栏目完成')
# 9. 最终截图
page.screenshot(path='e:/nanocode/website-new/screenshot_09_final.png')
print('Step 9: 最终页面截图')
browser.close()
print('All done!')
@@ -0,0 +1,7 @@
from sqlalchemy import create_engine, text
engine = create_engine('sqlite:///cms.db')
with engine.connect() as conn:
conn.execute(text("ALTER TABLE columns ADD COLUMN icon_name VARCHAR(50)"))
conn.commit()
print('icon_name column added successfully')
@@ -0,0 +1 @@
# app package
@@ -0,0 +1,86 @@
# app/auth.py
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
import bcrypt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.config import get_settings
from app.database import get_db
from app.models.admin import Admin
settings = get_settings()
security = HTTPBearer()
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
def get_password_hash(password: str) -> str:
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def decode_token(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except JWTError:
return None
async def get_current_admin(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> Admin:
token = credentials.credentials
payload = decode_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
username: str = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
admin = db.query(Admin).filter(Admin.username == username).first()
if admin is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
return admin
async def get_optional_admin(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: Session = Depends(get_db)
) -> Optional[Admin]:
if not credentials:
return None
try:
return await get_current_admin(credentials, db)
except HTTPException:
return None
@@ -0,0 +1,32 @@
# app/config.py
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# App
APP_NAME: str = "飞点健康 CMS"
DEBUG: bool = True
# Database
DATABASE_URL: str = "sqlite:///./cms.db"
# JWT
SECRET_KEY: str = "your-secret-key-change-in-production"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 # 24 hours
# CORS
CORS_ORIGINS: list = ["http://localhost:5173","http://49.235.37.80:18888", "http://localhost:10002", "http://129.211.173.87:10002", "http://49.235.37.80:8888"]
# Upload
UPLOAD_DIR: str = "../storage/admin/images"
MAX_UPLOAD_SIZE: int = 5 * 1024 * 1024 # 5MB
class Config:
env_file = ".env"
@lru_cache()
def get_settings():
return Settings()
@@ -0,0 +1,24 @@
# app/database.py
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.config import get_settings
settings = get_settings()
engine = create_engine(
settings.DATABASE_URL,
connect_args={"check_same_thread": False} if settings.DATABASE_URL.startswith("sqlite") else {}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@@ -0,0 +1,119 @@
# app/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
import os
import time
from app.config import get_settings
from app.database import engine, Base
from app.routers import auth, home_modules, nav_menus, settings as settings_router, upload, images, crawl, wechat_crawl, batch_wechat_crawl, contact_qrcodes, app_downloads, home_experts, home_partners, columns, articles, seo, prerender
settings = get_settings()
# Create database tables
Base.metadata.create_all(bind=engine)
app = FastAPI(
title=settings.APP_NAME,
description="飞点健康官网 CMS 系统 API",
version="1.0.0"
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Static files for uploads
# Use the actual uploads directory where crawl.py saves images
# Resolve relative to backend root to match config.UPLOAD_DIR
backend_root = os.path.dirname(os.path.dirname(__file__))
upload_dir = os.path.join(backend_root, settings.UPLOAD_DIR)
os.makedirs(upload_dir, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=upload_dir), name="uploads")
# New unified storage structure
storage_admin_dir = os.path.join(backend_root, "..", "storage", "admin")
storage_public_dir = os.path.join(backend_root, "..", "storage", "public")
storage_tmp_dir = os.path.join(backend_root, "..", "storage", "tmp")
os.makedirs(storage_admin_dir, exist_ok=True)
os.makedirs(storage_public_dir, exist_ok=True)
os.makedirs(storage_tmp_dir, exist_ok=True)
app.mount("/storage/admin", StaticFiles(directory=storage_admin_dir), name="storage_admin")
app.mount("/storage/public", StaticFiles(directory=storage_public_dir), name="storage_public")
app.mount("/storage/tmp", StaticFiles(directory=storage_tmp_dir), name="storage_tmp")
def cleanup_old_temp_files():
"""启动时清理超过24小时的采集临时文件"""
try:
if os.name == 'nt': # Windows
temp_dir = os.path.join(backend_root, "..", "storage", "tmp", "crawl")
else:
temp_dir = "/tmp/news_crawl"
if not os.path.exists(temp_dir):
return
now = time.time()
max_age = 24 * 3600 # 24小时
count = 0
for root, dirs, files in os.walk(temp_dir):
for f in files:
filepath = os.path.join(root, f)
try:
if now - os.path.getmtime(filepath) > max_age:
os.remove(filepath)
count += 1
except OSError:
pass
if count > 0:
print(f"[cleanup] 已清理 {count} 个过期临时文件")
except Exception as e:
print(f"[cleanup] 清理临时文件失败: {e}")
# 启动时执行清理
cleanup_old_temp_files()
# Routers
# SEO 路由(无 /api 前缀,直接暴露于根域名)
app.include_router(seo.router)
app.include_router(prerender.router)
app.include_router(auth.router, prefix="/api/auth", tags=["认证"])
app.include_router(home_modules.router, prefix="/api", tags=["首页模块"])
app.include_router(nav_menus.router, prefix="/api", tags=["导航菜单"])
app.include_router(settings_router.router, prefix="/api", tags=["系统设置"])
app.include_router(upload.router, prefix="/api", tags=["文件上传"])
app.include_router(images.router, prefix="/api", tags=["图片管理"])
app.include_router(crawl.router, prefix="/api", tags=["文章采集"])
app.include_router(wechat_crawl.router, prefix="/api", tags=["公众号采集"])
app.include_router(batch_wechat_crawl.router, prefix="/api", tags=["批量公众号采集"])
app.include_router(contact_qrcodes.router, prefix="/api", tags=["咨询二维码"])
app.include_router(app_downloads.router, prefix="/api", tags=["下载应用"])
app.include_router(home_experts.router, prefix="/api", tags=["专家团队"])
app.include_router(home_partners.router, prefix="/api", tags=["合作伙伴"])
app.include_router(columns.router, prefix="/api", tags=["栏目管理"])
app.include_router(articles.router, prefix="/api", tags=["内容管理"])
@app.get("/")
def root():
return {
"message": f"Welcome to {settings.APP_NAME}",
"docs": "/docs",
"version": "1.0.0"
}
@app.get("/health")
def health_check():
return {"status": "ok"}
@@ -0,0 +1,13 @@
# models package
from app.models.admin import Admin
from app.models.home_module import HomeModule
from app.models.nav_menu import NavMenu
from app.models.setting import Setting
from app.models.image import Image
from app.models.contact_qrcode import ContactQRCode
from app.models.app_download import AppDownload
from app.models.home_expert import HomeExpert
from app.models.home_partner import HomePartner
__all__ = ["Admin", "HomeModule", "NavMenu", "Setting", "Image", "ContactQRCode", "AppDownload", "HomeExpert", "HomePartner"]
@@ -0,0 +1,14 @@
# app/models/admin.py
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.sql import func
from app.database import Base
class Admin(Base):
__tablename__ = "admins"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, index=True, nullable=False)
password_hash = Column(String(255), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
@@ -0,0 +1,21 @@
# app/models/app_download.py
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.sql import func
from app.database import Base
class AppDownload(Base):
__tablename__ = "app_downloads"
id = Column(Integer, primary_key=True, index=True)
app_name = Column(String(100), nullable=False)
platform = Column(String(20), nullable=False)
download_url = Column(String(500), nullable=False)
description = Column(String(200), nullable=True)
icon = Column(String(255), nullable=True)
qr_image_path = Column(String(255), nullable=True)
sort_order = Column(Integer, default=0)
is_enabled = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
@@ -0,0 +1,52 @@
"""
内容管理模型
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, func
from sqlalchemy.orm import relationship
from app.database import Base
class Article(Base):
"""内容文章"""
__tablename__ = "articles"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(200), nullable=False, comment="标题")
slug = Column(String(200), unique=True, nullable=False, comment="URL友好的标识")
content = Column(Text, nullable=False, comment="正文")
content_type = Column(String(10), default="markdown", comment="内容类型:markdown/html")
summary = Column(String(500), comment="摘要")
cover_image = Column(String(500), comment="封面图路径")
column_id = Column(Integer, ForeignKey("columns.id"), nullable=False, comment="所属栏目ID")
author = Column(String(50), comment="作者")
source_url = Column(String(1000), nullable=True, comment="来源URL(公众号等采集来源,用于去重)")
status = Column(String(20), default="draft", comment="状态:published/draft")
sort_order = Column(Integer, default=0, comment="排序权重")
view_count = Column(Integer, default=0, comment="浏览次数")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
# 关联
column = relationship("ColumnModel", backref="articles")
def to_dict(self, include_content: bool = False):
"""转换为字典"""
data = {
"id": self.id,
"title": self.title,
"slug": self.slug,
"summary": self.summary,
"cover_image": self.cover_image,
"column_id": self.column_id,
"column": self.column.to_dict() if self.column else None,
"content_type": self.content_type,
"author": self.author,
"status": self.status,
"sort_order": self.sort_order,
"view_count": self.view_count,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
if include_content:
data["content"] = self.content
return data
@@ -0,0 +1,48 @@
"""
栏目管理模型
"""
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, func
from sqlalchemy.orm import relationship
from app.database import Base
class ColumnModel(Base):
"""CMS 栏目(两级架构:顶级栏目 + 二级子栏目)"""
__tablename__ = "columns"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), nullable=False, comment="栏目名称")
slug = Column(String(50), unique=True, nullable=False, comment="URL标识")
description = Column(String(200), comment="栏目描述")
parent_id = Column(Integer, ForeignKey("columns.id"), nullable=True, comment="父栏目ID")
sort_order = Column(Integer, default=0, comment="排序权重")
is_enabled = Column(Boolean, default=True, comment="是否前端可见")
display_mode = Column(String(20), default="list", comment="展示模式: list|card")
layout_style = Column(String(20), default="left", comment="布局样式: left|right|top")
icon_name = Column(String(50), nullable=True, comment="图标名称(lucide图标)")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
# 自关联关系
parent = relationship("ColumnModel", remote_side=[id], back_populates="children")
children = relationship("ColumnModel", back_populates="parent", cascade="all, delete-orphan")
def to_dict(self, include_content_count: bool = False):
"""转换为字典"""
data = {
"id": self.id,
"name": self.name,
"slug": self.slug,
"description": self.description,
"parent_id": self.parent_id,
"sort_order": self.sort_order,
"is_enabled": self.is_enabled,
"display_mode": self.display_mode,
"layout_style": self.layout_style,
"icon_name": self.icon_name,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
if include_content_count and hasattr(self, "content_count"):
data["content_count"] = self.content_count
return data
@@ -0,0 +1,18 @@
# app/models/contact_qrcode.py
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.sql import func
from app.database import Base
class ContactQRCode(Base):
__tablename__ = "contact_qrcodes"
id = Column(Integer, primary_key=True, index=True)
content = Column(String(500), nullable=False)
description = Column(String(200), nullable=True)
image_path = Column(String(255), nullable=True)
sort_order = Column(Integer, default=0)
is_enabled = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
@@ -0,0 +1,16 @@
# app/models/home_expert.py
from sqlalchemy import Column, Integer, Boolean, DateTime
from sqlalchemy.sql import func
from app.database import Base
class HomeExpert(Base):
__tablename__ = "home_experts"
id = Column(Integer, primary_key=True, index=True)
post_id = Column(Integer, nullable=False, index=True)
sort_order = Column(Integer, default=0)
is_enabled = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
@@ -0,0 +1,22 @@
# app/models/home_module.py
from sqlalchemy import Column, Integer, String, Boolean, Text, DateTime
from sqlalchemy.sql import func
from app.database import Base
class HomeModule(Base):
__tablename__ = "home_modules"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), nullable=False) # 显示名称
code = Column(String(30), unique=True, index=True, nullable=False) # 唯一标识
type = Column(String(20), nullable=False) # 模块类型
sort_order = Column(Integer, default=0) # 排序权重
is_enabled = Column(Boolean, default=True) # 是否启用
is_fixed = Column(Boolean, default=False) # 是否固定(不可删除)
title = Column(String(100)) # 模块标题
subtitle = Column(String(200)) # 副标题
content = Column(Text) # 内容数据(JSON字符串)
style_config = Column(Text) # 样式配置(JSON字符串)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
@@ -0,0 +1,19 @@
# app/models/home_partner.py
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.sql import func
from app.database import Base
class HomePartner(Base):
__tablename__ = "home_partners"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False, comment="伙伴名称")
logo_url = Column(String(500), comment="Logo图片路径")
description = Column(String(500), comment="简介")
website_url = Column(String(500), comment="网站URL")
sort_order = Column(Integer, default=0)
is_enabled = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
@@ -0,0 +1,39 @@
"""
图片资源模型
复用自 personal-blog,添加 module 字段支持多模块共用
"""
from sqlalchemy import Column, Integer, String, DateTime, func
from app.database import Base
class Image(Base):
"""上传的图片资源"""
__tablename__ = "images"
id = Column(Integer, primary_key=True, index=True)
filename = Column(String(255), nullable=False, comment="存储文件名")
original_name = Column(String(255), comment="原始文件名")
file_path = Column(String(500), nullable=False, comment="原图路径")
thumb_path = Column(String(500), comment="缩略图路径")
description = Column(String(500), comment="图片描述")
file_size = Column(Integer, comment="文件大小(字节)")
width = Column(Integer, comment="图片宽度")
height = Column(Integer, comment="图片高度")
module = Column(String(20), default="news", comment="所属模块:news/product/page等")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
def to_dict(self):
"""转换为字典"""
return {
"id": self.id,
"filename": self.filename,
"original_name": self.original_name,
"file_path": self.file_path,
"thumb_path": self.thumb_path,
"description": self.description,
"file_size": self.file_size,
"width": self.width,
"height": self.height,
"module": self.module,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
@@ -0,0 +1,22 @@
# app/models/nav_menu.py
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, DateTime
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship, backref
from app.database import Base
class NavMenu(Base):
__tablename__ = "nav_menus"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), nullable=False)
link = Column(String(255), nullable=False)
sort_order = Column(Integer, default=0)
is_enabled = Column(Boolean, default=True)
is_external = Column(Boolean, default=False)
parent_id = Column(Integer, ForeignKey("nav_menus.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# Relationship for sub-menus
children = relationship("NavMenu", backref=backref("parent", remote_side=[id]))
@@ -0,0 +1,39 @@
# ⚠️ 此新闻模块已废弃,不再使用。
# 已被「栏目管理 + 内容管理」(columns.py + articles.py)取代。
# 请勿删除此注释,供后续维护人员参考。
"""
新闻栏目模型(已废弃)
"""
from sqlalchemy import Column, Integer, String, DateTime, Boolean, func
from app.database import Base
class NewsCategory(Base):
"""新闻栏目/分类"""
__tablename__ = "news_categories"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), nullable=False, comment="栏目名称")
slug = Column(String(50), unique=True, nullable=False, comment="URL标识")
description = Column(String(200), comment="栏目描述")
sort_order = Column(Integer, default=0, comment="排序权重")
is_enabled = Column(Boolean, default=True, comment="是否启用")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
def to_dict(self, include_post_count: bool = False):
"""转换为字典"""
data = {
"id": self.id,
"name": self.name,
"slug": self.slug,
"description": self.description,
"sort_order": self.sort_order,
"is_enabled": self.is_enabled,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
if include_post_count and hasattr(self, "post_count"):
data["post_count"] = self.post_count
return data
@@ -0,0 +1,52 @@
# ⚠️ 此新闻模块已废弃,不再使用。
# 已被「栏目管理 + 内容管理」(columns.py + articles.py)取代。
# 请勿删除此注释,供后续维护人员参考。
"""
新闻资讯模型(已废弃)
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey, func
from sqlalchemy.orm import relationship
from app.database import Base
class NewsPost(Base):
"""新闻资讯文章"""
__tablename__ = "news_posts"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(200), nullable=False, comment="资讯标题")
slug = Column(String(200), unique=True, nullable=False, comment="URL友好的标识")
content = Column(Text, nullable=False, comment="内容")
content_type = Column(String(10), default="markdown", comment="内容类型:markdown/html")
summary = Column(String(500), comment="摘要")
cover_image = Column(String(500), comment="封面图路径")
category_id = Column(Integer, ForeignKey("news_categories.id"), nullable=False, comment="栏目ID")
is_published = Column(Boolean, default=False, comment="是否发布")
is_draft = Column(Boolean, default=True, comment="是否草稿")
view_count = Column(Integer, default=0, comment="浏览次数")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
# 关联
category = relationship("NewsCategory", backref="posts")
def to_dict(self, include_content: bool = False):
"""转换为字典"""
data = {
"id": self.id,
"title": self.title,
"slug": self.slug,
"summary": self.summary,
"cover_image": self.cover_image,
"category": self.category.to_dict() if self.category else None,
"content_type": self.content_type,
"is_published": self.is_published,
"is_draft": self.is_draft,
"view_count": self.view_count,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
if include_content:
data["content"] = self.content
return data
@@ -0,0 +1,16 @@
# app/models/setting.py
from sqlalchemy import Column, Integer, String, Text, DateTime
from sqlalchemy.sql import func
from app.database import Base
class Setting(Base):
__tablename__ = "settings"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(50), unique=True, index=True, nullable=False)
value = Column(Text)
group = Column(String(20), default="general")
description = Column(String(200))
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
@@ -0,0 +1 @@
# routers package
@@ -0,0 +1,210 @@
# app/routers/app_downloads.py
import os
import uuid
from typing import List, Union
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
import qrcode
from app.database import get_db
from app.models.app_download import AppDownload
from app.models.admin import Admin
from app.auth import get_current_admin
router = APIRouter()
# Schemas
class AppDownloadCreate(BaseModel):
app_name: str
platform: str
download_url: str
description: str = ""
icon: str = ""
sort_order: int = 0
is_enabled: bool = True
class AppDownloadUpdate(BaseModel):
app_name: str = None
platform: str = None
download_url: str = None
description: str = None
icon: str = None
sort_order: int = None
is_enabled: bool = None
class AppDownloadResponse(BaseModel):
code: int = 200
data: Union[dict, list] = None
message: str = "success"
# Helper
def get_qrcode_dir():
routers_dir = os.path.dirname(os.path.abspath(__file__))
app_dir = os.path.dirname(routers_dir)
backend_dir = os.path.dirname(app_dir)
project_root = os.path.dirname(backend_dir)
qrcode_dir = os.path.join(project_root, "storage", "admin", "images", "qrcode")
os.makedirs(qrcode_dir, exist_ok=True)
return qrcode_dir
def generate_qrcode_image(content: str, filename: str) -> str:
"""Generate QR code image and save to storage, return URL path"""
qrcode_dir = get_qrcode_dir()
filepath = os.path.join(qrcode_dir, filename)
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_H,
box_size=10,
border=4,
)
qr.add_data(content)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
img.save(filepath)
return f"/storage/admin/images/qrcode/{filename}"
def delete_qrcode_image(image_path: str):
"""Delete QR code image file if exists"""
if not image_path:
return
filename = os.path.basename(image_path)
filepath = os.path.join(get_qrcode_dir(), filename)
if os.path.exists(filepath):
os.remove(filepath)
def app_download_to_dict(ad: AppDownload) -> dict:
return {
"id": ad.id,
"app_name": ad.app_name,
"platform": ad.platform,
"download_url": ad.download_url,
"description": ad.description,
"icon": ad.icon,
"qr_image_path": ad.qr_image_path,
"sort_order": ad.sort_order,
"is_enabled": ad.is_enabled,
}
# Public routes
@router.get("/app-downloads", response_model=AppDownloadResponse)
def get_public_app_downloads(db: Session = Depends(get_db)):
"""Get enabled app downloads for public display"""
items = db.query(AppDownload).filter(
AppDownload.is_enabled == True
).order_by(AppDownload.sort_order.asc()).all()
return AppDownloadResponse(data=[app_download_to_dict(i) for i in items])
# Admin routes
@router.get("/admin/app-downloads", response_model=AppDownloadResponse)
def get_admin_app_downloads(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Get all app downloads for admin"""
items = db.query(AppDownload).order_by(AppDownload.sort_order.asc()).all()
return AppDownloadResponse(data=[app_download_to_dict(i) for i in items])
@router.post("/admin/app-downloads", response_model=AppDownloadResponse)
def create_app_download(
request: AppDownloadCreate,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Create new app download, auto-generate QR code"""
if not request.app_name.strip():
raise HTTPException(status_code=400, detail="应用名称不能为空")
if not request.download_url.strip():
raise HTTPException(status_code=400, detail="下载链接不能为空")
if not request.platform.strip():
raise HTTPException(status_code=400, detail="平台不能为空")
# Generate QR code
filename = f"app_download_{uuid.uuid4().hex[:8]}.png"
qr_image_path = generate_qrcode_image(request.download_url.strip(), filename)
item = AppDownload(
app_name=request.app_name.strip(),
platform=request.platform.strip(),
download_url=request.download_url.strip(),
description=request.description.strip() if request.description else "",
icon=request.icon.strip() if request.icon else "",
qr_image_path=qr_image_path,
sort_order=request.sort_order,
is_enabled=request.is_enabled,
)
db.add(item)
db.commit()
db.refresh(item)
return AppDownloadResponse(message="创建成功", data=app_download_to_dict(item))
@router.put("/admin/app-downloads/{item_id}", response_model=AppDownloadResponse)
def update_app_download(
item_id: int,
request: AppDownloadUpdate,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Update app download, regenerate QR code if URL changed"""
item = db.query(AppDownload).filter(AppDownload.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="下载链接不存在")
update_data = request.dict(exclude_unset=True)
# If download_url changed, regenerate QR code
new_url = update_data.get("download_url", "").strip()
if new_url and new_url != item.download_url:
delete_qrcode_image(item.qr_image_path)
filename = f"app_download_{uuid.uuid4().hex[:8]}.png"
item.qr_image_path = generate_qrcode_image(new_url, filename)
item.download_url = new_url
update_data.pop("download_url", None)
for field, value in update_data.items():
if field == "app_name" and value:
value = value.strip()
elif field == "platform" and value:
value = value.strip()
elif field == "description" and value is not None:
value = value.strip()
elif field == "icon" and value is not None:
value = value.strip()
setattr(item, field, value)
db.commit()
db.refresh(item)
return AppDownloadResponse(message="更新成功", data=app_download_to_dict(item))
@router.delete("/admin/app-downloads/{item_id}", response_model=AppDownloadResponse)
def delete_app_download(
item_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Delete app download and its QR code image"""
item = db.query(AppDownload).filter(AppDownload.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="下载链接不存在")
delete_qrcode_image(item.qr_image_path)
db.delete(item)
db.commit()
return AppDownloadResponse(message="删除成功")
@@ -0,0 +1,526 @@
"""
内容管理 API 路由
复用新闻资讯模块的核心逻辑,关联到新的栏目(columns)体系
"""
import html
import os
import re
import shutil
import subprocess
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import func
from pydantic import BaseModel, Field
from pypinyin import lazy_pinyin
from app.models.article import Article
from app.models.column import ColumnModel
from app.models.image import Image
from app.routers.auth import get_current_admin
from app.utils.file_utils import cleanup_file_by_path
from app.database import get_db
router = APIRouter(tags=["内容管理"])
# ========== 辅助函数 ==========
def extract_summary(content: str, max_length: int = 100) -> str:
"""从 HTML/Markdown 内容中提取纯文本摘要"""
if not content:
return ""
text = re.sub(r'<[^>]+>', '', content)
text = html.unescape(text)
text = re.sub(r'[#*_`~\[\]\(\)!>-]', '', text)
text = re.sub(r'\s+', ' ', text).strip()
if len(text) > max_length:
return text[:max_length] + "..."
return text
def generate_slug(text: str) -> str:
"""将中文标题转为拼音 slug"""
if not text:
return ""
py = lazy_pinyin(text, style=0)
slug = "-".join(py)
slug = re.sub(r"[^a-zA-Z0-9-]", "", slug).lower()
slug = re.sub(r"-+", "-", slug).strip("-")
return slug or f"article-{int(__import__('time').time())}"
TEMP_STORAGE_PREFIX = "/storage/tmp/crawl"
def get_upload_dir():
"""获取上传目录路径"""
from app.config import get_settings
settings = get_settings()
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
return os.path.join(base_dir, settings.UPLOAD_DIR)
def get_crawl_temp_dir():
"""获取采集临时目录 - 跨平台"""
if os.name == 'nt':
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
return os.path.join(base_dir, 'storage', 'tmp', 'crawl')
return '/tmp/news_crawl'
def migrate_temp_images(content: str, cover_image: Optional[str], db: Session):
"""将内容中的临时图片迁移到正式目录,并写入 images 表"""
upload_dir = get_upload_dir()
temp_dir = get_crawl_temp_dir()
temp_paths = set()
if cover_image and cover_image.startswith(TEMP_STORAGE_PREFIX):
temp_paths.add(cover_image)
pattern = re.escape(TEMP_STORAGE_PREFIX) + r'/[^\\)"\'>\s]+'
for match in re.finditer(pattern, content):
temp_paths.add(match.group())
if not temp_paths:
return content, cover_image
# 按年月分目录存储(与 batch_crawl / wechat_crawl 一致)
year_month = datetime.now().strftime("%Y%m")
dest_dir = os.path.join(upload_dir, year_month)
os.makedirs(dest_dir, exist_ok=True)
path_mapping = {}
for temp_path in temp_paths:
filename = os.path.basename(temp_path)
temp_file = os.path.join(temp_dir, filename)
if not os.path.exists(temp_file):
continue
dest_file = os.path.join(dest_dir, filename)
shutil.move(temp_file, dest_file)
thumb_filename = f"{os.path.splitext(filename)[0]}.jpg"
temp_thumb = os.path.join(temp_dir, "thumbnails", thumb_filename)
dest_thumb = os.path.join(upload_dir, "thumbnails", thumb_filename)
if os.path.exists(temp_thumb):
shutil.move(temp_thumb, dest_thumb)
formal_path = f"/storage/admin/images/{year_month}/{filename}"
formal_thumb = f"/storage/admin/images/thumbnails/{thumb_filename}"
path_mapping[temp_path] = formal_path
try:
file_size = os.path.getsize(dest_file)
except OSError:
file_size = 0
existing = db.query(Image).filter(Image.file_path == formal_path).first()
if not existing:
img_record = Image(
filename=filename,
original_name=filename,
file_path=formal_path,
thumb_path=formal_thumb,
file_size=file_size,
module="articles"
)
db.add(img_record)
new_content = content
for temp_path, formal_path in path_mapping.items():
new_content = new_content.replace(temp_path, formal_path)
new_cover = cover_image
if cover_image and cover_image in path_mapping:
new_cover = path_mapping[cover_image]
return new_content, new_cover
def _validate_column_for_article(column_id: int, db: Session, exclude_article_id: Optional[int] = None):
"""校验栏目是否可关联内容:
- 栏目必须存在
- 顶级栏目若有二级栏目,则禁止直接关联内容
"""
column = db.query(ColumnModel).filter(ColumnModel.id == column_id).first()
if not column:
raise HTTPException(status_code=404, detail="栏目不存在")
# 如果是顶级栏目,检查是否有子栏目
if column.parent_id is None:
child_count = db.query(ColumnModel).filter(ColumnModel.parent_id == column_id).count()
if child_count > 0:
raise HTTPException(status_code=400, detail="该栏目存在子栏目,内容必须关联到子栏目")
return column
# ========== Pydantic 模型 ==========
class ArticleCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
slug: Optional[str] = Field(None, max_length=200)
content: str = Field(..., min_length=1)
content_type: str = Field(default="markdown", pattern="^(markdown|html)$")
summary: Optional[str] = Field(None, max_length=500)
cover_image: Optional[str] = Field(None, max_length=500)
column_id: int
author: Optional[str] = Field(None, max_length=50)
status: str = Field(default="draft", pattern="^(published|draft)$")
sort_order: int = Field(default=0)
class ArticleUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=200)
slug: Optional[str] = Field(None, min_length=1, max_length=200)
content: Optional[str] = Field(None, min_length=1)
content_type: Optional[str] = Field(None, pattern="^(markdown|html)$")
summary: Optional[str] = Field(None, max_length=500)
cover_image: Optional[str] = Field(None, max_length=500)
column_id: Optional[int] = None
author: Optional[str] = Field(None, max_length=50)
status: Optional[str] = Field(None, pattern="^(published|draft)$")
sort_order: Optional[int] = None
class ArticlePublish(BaseModel):
is_published: bool
class BatchDelete(BaseModel):
ids: List[int]
class BatchMove(BaseModel):
ids: List[int]
column_id: int
# ========== API ==========
@router.get("/admin/articles")
def list_articles(
page: int = Query(default=1, ge=1),
size: int = Query(default=10, ge=1, le=100),
column_id: Optional[int] = Query(default=None),
keyword: Optional[str] = Query(default=None),
status: Optional[str] = Query(default=None, pattern="^(published|draft|rewriting|all)$"),
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""获取内容列表(分页 + 筛选)"""
query = db.query(Article).options(joinedload(Article.column))
if column_id:
query = query.filter(Article.column_id == column_id)
if keyword:
query = query.filter(Article.title.contains(keyword))
if status == "published":
query = query.filter(Article.status == "published")
elif status == "draft":
query = query.filter(Article.status == "draft")
query = query.order_by(Article.updated_at.desc())
total = query.count()
articles = query.offset((page - 1) * size).limit(size).all()
return {
"items": [a.to_dict(include_content=False) for a in articles],
"total": total,
"page": page,
"size": size
}
@router.get("/admin/articles/{article_id}")
def get_article(
article_id: int,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""获取内容详情"""
article = db.query(Article).options(joinedload(Article.column)).filter(Article.id == article_id).first()
if not article:
raise HTTPException(status_code=404, detail="内容不存在")
return article.to_dict(include_content=True)
@router.post("/admin/articles")
def create_article(
data: ArticleCreate,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""创建内容"""
# 校验栏目
_validate_column_for_article(data.column_id, db)
# 自动生成 slug
slug = data.slug or generate_slug(data.title)
if not slug:
raise HTTPException(status_code=400, detail="URL标识生成失败,请手动填写")
existing = db.query(Article).filter(Article.slug == slug).first()
if existing:
raise HTTPException(status_code=400, detail="内容标识已存在")
# 自动生成摘要
summary = data.summary
if not summary:
summary = extract_summary(data.content, max_length=100)
# 迁移临时图片
migrated_content, migrated_cover = migrate_temp_images(data.content, data.cover_image, db)
article = Article(
title=data.title,
slug=slug,
content=migrated_content,
content_type=data.content_type,
summary=summary,
cover_image=migrated_cover,
column_id=data.column_id,
author=data.author,
status=data.status,
sort_order=data.sort_order,
)
db.add(article)
db.commit()
db.refresh(article)
return article.to_dict(include_content=True)
@router.put("/admin/articles/{article_id}")
def update_article(
article_id: int,
data: ArticleUpdate,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""更新内容"""
article = db.query(Article).filter(Article.id == article_id).first()
if not article:
raise HTTPException(status_code=404, detail="内容不存在")
# 检查 slug 冲突
slug = data.slug
if slug is not None:
if not slug.strip():
slug = generate_slug(data.title or article.title)
if slug != article.slug:
existing = db.query(Article).filter(Article.slug == slug).first()
if existing:
raise HTTPException(status_code=400, detail="内容标识已存在")
# 校验栏目
if data.column_id is not None:
_validate_column_for_article(data.column_id, db, exclude_article_id=article_id)
update_data = data.model_dump(exclude_unset=True)
if slug is not None:
update_data["slug"] = slug
# 摘要被清空时自动提取
if "summary" in update_data and not update_data.get("summary", "").strip():
content_to_extract = update_data.get("content") or article.content
if content_to_extract:
update_data["summary"] = extract_summary(content_to_extract, max_length=100)
# 迁移临时图片(如果内容/封面包含临时路径)
if update_data.get("content"):
migrated_content, migrated_cover = migrate_temp_images(
update_data["content"],
update_data.get("cover_image") or article.cover_image,
db,
)
update_data["content"] = migrated_content
update_data["cover_image"] = migrated_cover
for key, value in update_data.items():
setattr(article, key, value)
db.commit()
db.refresh(article)
return article.to_dict(include_content=True)
@router.delete("/admin/articles/{article_id}")
def delete_article(
article_id: int,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""删除内容"""
article = db.query(Article).filter(Article.id == article_id).first()
if not article:
raise HTTPException(status_code=404, detail="内容不存在")
if article.cover_image:
cleanup_file_by_path(article.cover_image)
db.delete(article)
db.commit()
return {"message": "删除成功"}
@router.post("/admin/articles/batch-delete")
def batch_delete_articles(
data: BatchDelete,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""批量删除内容"""
articles = db.query(Article).filter(Article.id.in_(data.ids)).all()
for article in articles:
if article.cover_image:
cleanup_file_by_path(article.cover_image)
db.delete(article)
db.commit()
return {"message": f"成功删除 {len(articles)} 条内容"}
@router.post("/admin/articles/batch-move")
def batch_move_articles(
data: BatchMove,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""批量转移栏目"""
# 校验目标栏目
_validate_column_for_article(data.column_id, db)
articles = db.query(Article).filter(Article.id.in_(data.ids)).all()
for article in articles:
article.column_id = data.column_id
db.commit()
return {"message": f"成功转移 {len(articles)} 条内容"}
@router.post("/admin/articles/{article_id}/publish")
def publish_article(
article_id: int,
data: ArticlePublish,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""发布/取消发布内容"""
article = db.query(Article).filter(Article.id == article_id).first()
if not article:
raise HTTPException(status_code=404, detail="内容不存在")
article.status = "published" if data.is_published else "draft"
db.commit()
db.refresh(article)
return article.to_dict()
# ========== 改写发布 ==========
VENV_PYTHON = "/mnt/datadisk0/www/new-media-platform/website-new/backend/.venv/bin/python3"
PIPELINE_PATH = "/mnt/datadisk0/www/new-media-platform/website-new/rewrite/pipeline.py"
LOG_FILE = "/mnt/datadisk0/www/new-media-platform/website-new/rewrite/logs/pipeline.log"
@router.post("/admin/articles/{article_id}/rewrite-publish")
def rewrite_publish_article(
article_id: int,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""一键改写+发布到公众号(异步后台任务)"""
article = db.query(Article).filter(Article.id == article_id).first()
if not article:
raise HTTPException(status_code=404, detail="内容不存在")
if article.status != "draft":
raise HTTPException(status_code=400, detail="只有草稿状态的内容可以改写发布")
# 并发控制:检查是否有正在改写中的文章
rewriting_count = db.query(Article).filter(
Article.status == "rewriting"
).count()
if rewriting_count > 0:
raise HTTPException(status_code=429, detail="已有文章正在改写中,请稍后再试")
# 设置为改写中
article.status = "rewriting"
db.commit()
db.refresh(article)
# 启动后台子进程,stderr 写入日志文件便于排查
stderr_log = open(LOG_FILE, "a")
subprocess.Popen(
[VENV_PYTHON, PIPELINE_PATH, str(article_id)],
stdout=stderr_log,
stderr=stderr_log,
)
return {
"message": "改写任务已启动",
"article_id": article_id,
"status": "rewriting",
}
# ========== 访客端 Public API ==========
@router.get("/public/articles")
def list_public_articles(
page: int = Query(default=1, ge=1),
size: int = Query(default=10, ge=1, le=100),
column_slug: Optional[str] = Query(default=None),
db: Session = Depends(get_db),
):
"""访客端:获取已发布的内容列表"""
query = db.query(Article).options(joinedload(Article.column)).filter(
Article.status == "published"
)
# 如果指定了栏目 slug,筛选该栏目及其子栏目的内容
if column_slug:
column = db.query(ColumnModel).filter(
ColumnModel.slug == column_slug,
ColumnModel.is_enabled == True
).first()
if column:
# 获取该栏目及所有子栏目的 ID
column_ids = [column.id]
children = db.query(ColumnModel).filter(
ColumnModel.parent_id == column.id
).all()
column_ids.extend([c.id for c in children])
query = query.filter(Article.column_id.in_(column_ids))
query = query.order_by(Article.sort_order.asc(), Article.updated_at.desc())
total = query.count()
articles = query.offset((page - 1) * size).limit(size).all()
return {
"items": [a.to_dict(include_content=False) for a in articles],
"total": total,
"page": page,
"size": size
}
@router.get("/public/articles/{slug}")
def get_public_article(
slug: str,
db: Session = Depends(get_db),
):
"""访客端:通过 slug 获取内容详情"""
article = db.query(Article).options(joinedload(Article.column)).filter(
Article.slug == slug,
Article.status == "published"
).first()
if not article:
raise HTTPException(status_code=404, detail="内容不存在")
# 增加浏览次数
article.view_count += 1
db.commit()
return article.to_dict(include_content=True)
@@ -0,0 +1,93 @@
# app/routers/auth.py
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.config import get_settings
from app.database import get_db
from app.models.admin import Admin
from app.auth import verify_password, get_password_hash, create_access_token, get_current_admin
router = APIRouter()
settings = get_settings()
security = HTTPBearer()
# Schemas
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
code: int = 200
message: str
data: dict
class AdminInfoResponse(BaseModel):
code: int = 200
data: dict
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
# Routes
@router.post("/login", response_model=LoginResponse)
def login(request: LoginRequest, db: Session = Depends(get_db)):
admin = db.query(Admin).filter(Admin.username == request.username).first()
if not admin or not verify_password(request.password, admin.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误"
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": admin.username}, expires_delta=access_token_expires
)
return LoginResponse(
message="登录成功",
data={
"token": access_token,
"admin": {
"id": admin.id,
"username": admin.username
}
}
)
@router.get("/me", response_model=AdminInfoResponse)
def get_me(current_admin: Admin = Depends(get_current_admin)):
return AdminInfoResponse(
data={
"id": current_admin.id,
"username": current_admin.username
}
)
@router.post("/change-password")
def change_password(
request: ChangePasswordRequest,
current_admin: Admin = Depends(get_current_admin),
db: Session = Depends(get_db)
):
if not verify_password(request.old_password, current_admin.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="原密码错误"
)
current_admin.password_hash = get_password_hash(request.new_password)
db.commit()
return {"code": 200, "message": "密码修改成功"}
@@ -0,0 +1,297 @@
"""
栏目管理 API 路由
"""
import re
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from pydantic import BaseModel, Field
from pypinyin import lazy_pinyin
from app.models.column import ColumnModel
from app.routers.auth import get_current_admin
from app.database import get_db
router = APIRouter(tags=["栏目管理"])
def generate_slug(text: str) -> str:
"""将中文标题转为拼音 slug"""
if not text:
return ""
py = lazy_pinyin(text, style=0)
slug = "-".join(py)
slug = re.sub(r"[^a-zA-Z0-9-]", "", slug).lower()
slug = re.sub(r"-+", "-", slug).strip("-")
return slug or f"column-{int(__import__('time').time())}"
# ========== Pydantic 模型 ==========
class ColumnCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=50)
slug: Optional[str] = Field(None, max_length=50)
description: Optional[str] = Field(None, max_length=200)
parent_id: Optional[int] = Field(None)
sort_order: int = Field(default=0)
is_enabled: bool = Field(default=True)
display_mode: str = Field(default="list", pattern="^(list|card)$")
layout_style: str = Field(default="left", pattern="^(left|right|top)$")
icon_name: Optional[str] = Field(None, max_length=50)
class ColumnUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=50)
slug: Optional[str] = Field(None, min_length=1, max_length=50)
description: Optional[str] = Field(None, max_length=200)
parent_id: Optional[int] = Field(None)
sort_order: Optional[int] = None
is_enabled: Optional[bool] = None
display_mode: Optional[str] = Field(None, pattern="^(list|card)$")
layout_style: Optional[str] = Field(None, pattern="^(left|right|top)$")
icon_name: Optional[str] = Field(None, max_length=50)
class ColumnTreeOut(BaseModel):
"""树形栏目输出"""
id: int
name: str
slug: str
description: Optional[str]
parent_id: Optional[int]
sort_order: int
is_enabled: bool
display_mode: str
layout_style: str
children: List["ColumnTreeOut"] = []
class Config:
from_attributes = True
# ========== 辅助函数 ==========
def _build_tree(columns: list, parent_id: Optional[int] = None) -> list:
"""递归构建栏目树"""
tree = []
for col in columns:
if col.parent_id == parent_id:
node = col.to_dict()
node["children"] = _build_tree(columns, col.id)
tree.append(node)
return tree
def _validate_parent(column: ColumnModel, new_parent_id: Optional[int], db: Session):
"""校验 parent_id 是否合法:最多两级"""
if new_parent_id is None:
return
# 不能把自己设为自己的父栏目
if column and new_parent_id == column.id:
raise HTTPException(status_code=400, detail="不能将栏目设为自己的上级栏目")
# 父栏目必须存在
parent = db.query(ColumnModel).filter(ColumnModel.id == new_parent_id).first()
if not parent:
raise HTTPException(status_code=404, detail="上级栏目不存在")
# 父栏目本身不能是二级栏目(最多两级)
if parent.parent_id is not None:
raise HTTPException(status_code=400, detail="最多支持两级栏目,不能将二级栏目设为上级")
def _has_children(column_id: int, db: Session) -> bool:
"""检查栏目是否有子栏目"""
return db.query(ColumnModel).filter(ColumnModel.parent_id == column_id).first() is not None
# ========== API ==========
@router.get("/admin/columns")
def list_columns(
tree: bool = Query(default=False, description="是否返回树形结构"),
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""获取栏目列表(后台管理)"""
columns = db.query(ColumnModel).order_by(ColumnModel.sort_order.asc()).all()
if tree:
return {"items": _build_tree(columns), "total": len(columns)}
return {"items": [col.to_dict() for col in columns], "total": len(columns)}
@router.get("/admin/columns/tree")
def get_column_tree(
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""获取树形栏目列表(用于下拉选择,后台管理)"""
columns = db.query(ColumnModel).order_by(ColumnModel.sort_order.asc()).all()
return {"items": _build_tree(columns), "total": len(columns)}
@router.get("/admin/columns/{column_id}")
def get_column(
column_id: int,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""获取栏目详情(后台管理)"""
column = db.query(ColumnModel).filter(ColumnModel.id == column_id).first()
if not column:
raise HTTPException(status_code=404, detail="栏目不存在")
return column.to_dict()
@router.post("/admin/columns")
def create_column(
data: ColumnCreate,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""创建栏目(后台管理)"""
# 校验上级栏目
_validate_parent(None, data.parent_id, db)
# 自动生成 slug
slug = data.slug or generate_slug(data.name)
if not slug:
raise HTTPException(status_code=400, detail="URL标识生成失败,请手动填写")
# 检查 slug 唯一性
existing = db.query(ColumnModel).filter(ColumnModel.slug == slug).first()
if existing:
raise HTTPException(status_code=400, detail="栏目标识已存在")
column = ColumnModel(
name=data.name,
slug=slug,
description=data.description,
parent_id=data.parent_id,
sort_order=data.sort_order,
is_enabled=data.is_enabled,
display_mode=data.display_mode,
layout_style=data.layout_style,
icon_name=data.icon_name,
)
db.add(column)
db.commit()
db.refresh(column)
return column.to_dict()
@router.put("/admin/columns/{column_id}")
def update_column(
column_id: int,
data: ColumnUpdate,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""更新栏目(后台管理)"""
column = db.query(ColumnModel).filter(ColumnModel.id == column_id).first()
if not column:
raise HTTPException(status_code=404, detail="栏目不存在")
# 校验上级栏目
if data.parent_id is not None:
_validate_parent(column, data.parent_id, db)
# 检查 slug 唯一性
slug = data.slug
if slug is not None:
if not slug.strip():
slug = generate_slug(data.name or column.name)
if slug != column.slug:
existing = db.query(ColumnModel).filter(ColumnModel.slug == slug).first()
if existing:
raise HTTPException(status_code=400, detail="栏目标识已存在")
update_data = data.model_dump(exclude_unset=True)
if slug is not None:
update_data["slug"] = slug
for key, value in update_data.items():
setattr(column, key, value)
db.commit()
db.refresh(column)
return column.to_dict()
@router.delete("/admin/columns/{column_id}")
def delete_column(
column_id: int,
force: bool = Query(default=False, description="强制删除关联内容"),
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""删除栏目(后台管理)"""
column = db.query(ColumnModel).filter(ColumnModel.id == column_id).first()
if not column:
raise HTTPException(status_code=404, detail="栏目不存在")
# 检查是否有子栏目
child_count = db.query(ColumnModel).filter(ColumnModel.parent_id == column_id).count()
if child_count > 0:
if not force:
raise HTTPException(
status_code=400,
detail=f"该栏目存在 {child_count} 个子栏目,请先删除子栏目或勾选强制删除"
)
# 强制删除:递归删除所有子栏目
db.query(ColumnModel).filter(ColumnModel.parent_id == column_id).delete(synchronize_session=False)
# TODO: 检查关联的内容数量(等 articles 模型创建后补充)
# content_count = db.query(Article).filter(Article.column_id == column_id).count()
# if content_count > 0:
# if not force:
# raise HTTPException(status_code=400, detail=f"该栏目下还有 {content_count} 条内容,是否强制删除?")
# db.query(Article).filter(Article.column_id == column_id).delete(synchronize_session=False)
db.delete(column)
db.commit()
return {"message": "删除成功"}
# ========== 访客端 Public API ==========
@router.get("/public/columns")
def list_public_columns(
db: Session = Depends(get_db),
):
"""访客端:获取启用的栏目树"""
columns = db.query(ColumnModel).filter(
ColumnModel.is_enabled == True
).order_by(ColumnModel.sort_order.asc()).all()
return {"items": _build_tree(columns), "total": len(columns)}
@router.get("/public/columns/{slug}")
def get_public_column(
slug: str,
db: Session = Depends(get_db),
):
"""访客端:获取栏目详情(包含子栏目)"""
column = db.query(ColumnModel).filter(
ColumnModel.slug == slug,
ColumnModel.is_enabled == True
).first()
if not column:
raise HTTPException(status_code=404, detail="栏目不存在")
result = column.to_dict()
# 加载子栏目
children = db.query(ColumnModel).filter(
ColumnModel.parent_id == column.id,
ColumnModel.is_enabled == True
).order_by(ColumnModel.sort_order.asc()).all()
result["children"] = [c.to_dict() for c in children]
# 加载栏目下的内容数量
from app.models.article import Article
article_count = db.query(Article).filter(
Article.column_id == column.id,
Article.status == "published"
).count()
result["article_count"] = article_count
return result
@@ -0,0 +1,190 @@
# app/routers/contact_qrcodes.py
import os
import uuid
from typing import List, Union
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
import qrcode
from qrcode.image.pil import PilImage
from app.database import get_db
from app.models.contact_qrcode import ContactQRCode
from app.models.admin import Admin
from app.auth import get_current_admin
from app.config import get_settings
router = APIRouter()
settings = get_settings()
# Schemas
class ContactQRCodeCreate(BaseModel):
content: str
description: str = ""
sort_order: int = 0
is_enabled: bool = True
class ContactQRCodeUpdate(BaseModel):
content: str = None
description: str = None
sort_order: int = None
is_enabled: bool = None
class ContactQRCodeResponse(BaseModel):
code: int = 200
data: Union[dict, list] = None
message: str = "success"
# Helper
def get_qrcode_dir():
# __file__ = backend/app/routers/contact_qrcodes.py
# 需要向上3级才能到达项目根目录
routers_dir = os.path.dirname(os.path.abspath(__file__)) # backend/app/routers
app_dir = os.path.dirname(routers_dir) # backend/app
backend_dir = os.path.dirname(app_dir) # backend
project_root = os.path.dirname(backend_dir) # project root
qrcode_dir = os.path.join(project_root, "storage", "admin", "images", "qrcode")
os.makedirs(qrcode_dir, exist_ok=True)
return qrcode_dir
def generate_qrcode_image(content: str, filename: str) -> str:
"""Generate QR code image and save to storage, return URL path"""
qrcode_dir = get_qrcode_dir()
filepath = os.path.join(qrcode_dir, filename)
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_H,
box_size=10,
border=4,
)
qr.add_data(content)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
img.save(filepath)
return f"/storage/admin/images/qrcode/{filename}"
def qrcode_to_dict(qr: ContactQRCode) -> dict:
return {
"id": qr.id,
"content": qr.content,
"description": qr.description,
"image_path": qr.image_path,
"sort_order": qr.sort_order,
"is_enabled": qr.is_enabled,
}
# Public routes
@router.get("/contact-qrcodes", response_model=ContactQRCodeResponse)
def get_public_qrcodes(db: Session = Depends(get_db)):
"""Get enabled QR codes for public display"""
qrcodes = db.query(ContactQRCode).filter(
ContactQRCode.is_enabled == True
).order_by(ContactQRCode.sort_order.asc()).all()
return ContactQRCodeResponse(data=[qrcode_to_dict(q) for q in qrcodes])
# Admin routes
@router.get("/admin/contact-qrcodes", response_model=ContactQRCodeResponse)
def get_admin_qrcodes(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Get all QR codes for admin"""
qrcodes = db.query(ContactQRCode).order_by(ContactQRCode.sort_order.asc()).all()
return ContactQRCodeResponse(data=[qrcode_to_dict(q) for q in qrcodes])
@router.post("/admin/contact-qrcodes", response_model=ContactQRCodeResponse)
def create_qrcode(
request: ContactQRCodeCreate,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Create new QR code, auto-generate image"""
if not request.content.strip():
raise HTTPException(status_code=400, detail="内容不能为空")
filename = f"qrcode_{uuid.uuid4().hex[:8]}.png"
image_path = generate_qrcode_image(request.content.strip(), filename)
qr = ContactQRCode(
content=request.content.strip(),
description=request.description.strip() if request.description else "",
image_path=image_path,
sort_order=request.sort_order,
is_enabled=request.is_enabled,
)
db.add(qr)
db.commit()
db.refresh(qr)
return ContactQRCodeResponse(message="创建成功", data=qrcode_to_dict(qr))
@router.put("/admin/contact-qrcodes/{qr_id}", response_model=ContactQRCodeResponse)
def update_qrcode(
qr_id: int,
request: ContactQRCodeUpdate,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Update QR code, regenerate image if content changed"""
qr = db.query(ContactQRCode).filter(ContactQRCode.id == qr_id).first()
if not qr:
raise HTTPException(status_code=404, detail="QR code not found")
update_data = request.dict(exclude_unset=True)
# If content changed, regenerate QR code image
new_content = update_data.get("content", "").strip()
if new_content and new_content != qr.content:
# Delete old image
if qr.image_path:
old_filename = os.path.basename(qr.image_path)
old_filepath = os.path.join(get_qrcode_dir(), old_filename)
if os.path.exists(old_filepath):
os.remove(old_filepath)
# Generate new image
filename = f"qrcode_{uuid.uuid4().hex[:8]}.png"
qr.image_path = generate_qrcode_image(new_content, filename)
qr.content = new_content
update_data.pop("content", None)
for field, value in update_data.items():
setattr(qr, field, value)
db.commit()
db.refresh(qr)
return ContactQRCodeResponse(message="更新成功", data=qrcode_to_dict(qr))
@router.delete("/admin/contact-qrcodes/{qr_id}", response_model=ContactQRCodeResponse)
def delete_qrcode(
qr_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Delete QR code and its image file"""
qr = db.query(ContactQRCode).filter(ContactQRCode.id == qr_id).first()
if not qr:
raise HTTPException(status_code=404, detail="QR code not found")
# Delete image file
if qr.image_path:
filename = os.path.basename(qr.image_path)
filepath = os.path.join(get_qrcode_dir(), filename)
if os.path.exists(filepath):
os.remove(filepath)
db.delete(qr)
db.commit()
return ContactQRCodeResponse(message="删除成功")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,184 @@
# app/routers/home_experts.py
# 已迁移到新栏目体系(columns/articles),post_id 语义现在指向 Article.id
from typing import List, Union, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.home_expert import HomeExpert
from app.models.article import Article
from app.models.admin import Admin
from app.auth import get_current_admin
router = APIRouter()
# Schemas
class HomeExpertCreate(BaseModel):
post_id: int # 语义:article.id
sort_order: int = 0
is_enabled: bool = True
class HomeExpertUpdate(BaseModel):
post_id: Optional[int] = None
sort_order: Optional[int] = None
is_enabled: Optional[bool] = None
class HomeExpertResponse(BaseModel):
code: int = 200
data: Union[dict, list] = None
message: str = "success"
# Helper
def expert_to_dict(expert: HomeExpert, article: Article = None) -> dict:
"""转换为字典。保持对外字段名 `post` 不变,避免破坏前端展示。"""
result = {
"id": expert.id,
"post_id": expert.post_id,
"sort_order": expert.sort_order,
"is_enabled": expert.is_enabled,
}
if article:
result["post"] = {
"id": article.id,
"title": article.title,
"slug": article.slug,
"summary": article.summary,
"cover_image": article.cover_image,
"column_id": article.column_id,
# 兼容老字段名
"category_id": article.column_id,
}
return result
# Public routes
@router.get("/home-experts", response_model=HomeExpertResponse)
def get_public_experts(
limit: int = 6,
db: Session = Depends(get_db)
):
"""访客端:获取启用的专家团队(关联已发布的文章)"""
experts = db.query(HomeExpert).filter(
HomeExpert.is_enabled == True
).order_by(HomeExpert.sort_order.asc()).limit(limit).all()
result = []
for expert in experts:
article = db.query(Article).filter(
Article.id == expert.post_id,
Article.status == "published"
).first()
if article:
result.append(expert_to_dict(expert, article))
return HomeExpertResponse(data=result)
# Admin routes
@router.get("/admin/home-experts", response_model=HomeExpertResponse)
def get_admin_experts(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""后台:获取所有专家团队(含文章信息)"""
experts = db.query(HomeExpert).order_by(HomeExpert.sort_order.asc()).all()
result = []
for expert in experts:
article = db.query(Article).filter(Article.id == expert.post_id).first()
result.append(expert_to_dict(expert, article))
return HomeExpertResponse(data=result)
@router.get("/admin/home-experts/available-posts", response_model=HomeExpertResponse)
def get_available_posts(
keyword: Optional[str] = None,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""后台:获取可选的已发布文章列表(供挑选作为专家)"""
query = db.query(Article).filter(Article.status == "published")
if keyword:
query = query.filter(Article.title.contains(keyword))
articles = query.order_by(Article.created_at.desc()).limit(50).all()
return HomeExpertResponse(data=[
{
"id": a.id,
"title": a.title,
"slug": a.slug,
"summary": a.summary,
"cover_image": a.cover_image,
"column_id": a.column_id,
"created_at": a.created_at.isoformat() if a.created_at else None,
}
for a in articles
])
@router.post("/admin/home-experts", response_model=HomeExpertResponse)
def create_expert(
request: HomeExpertCreate,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""添加一篇文章为专家团队成员"""
article = db.query(Article).filter(Article.id == request.post_id).first()
if not article:
raise HTTPException(status_code=404, detail="资讯不存在")
existing = db.query(HomeExpert).filter(HomeExpert.post_id == request.post_id).first()
if existing:
raise HTTPException(status_code=400, detail="该资讯已添加到专家团队")
expert = HomeExpert(
post_id=request.post_id,
sort_order=request.sort_order,
is_enabled=request.is_enabled,
)
db.add(expert)
db.commit()
db.refresh(expert)
return HomeExpertResponse(message="添加成功", data=expert_to_dict(expert, article))
@router.put("/admin/home-experts/{expert_id}", response_model=HomeExpertResponse)
def update_expert(
expert_id: int,
request: HomeExpertUpdate,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""更新专家团队条目"""
expert = db.query(HomeExpert).filter(HomeExpert.id == expert_id).first()
if not expert:
raise HTTPException(status_code=404, detail="专家不存在")
for field, value in request.dict(exclude_unset=True).items():
setattr(expert, field, value)
db.commit()
db.refresh(expert)
article = db.query(Article).filter(Article.id == expert.post_id).first()
return HomeExpertResponse(message="更新成功", data=expert_to_dict(expert, article))
@router.delete("/admin/home-experts/{expert_id}", response_model=HomeExpertResponse)
def delete_expert(
expert_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""从首页专家团队移除(不删除原文章)"""
expert = db.query(HomeExpert).filter(HomeExpert.id == expert_id).first()
if not expert:
raise HTTPException(status_code=404, detail="专家不存在")
db.delete(expert)
db.commit()
return HomeExpertResponse(message="移除成功")
@@ -0,0 +1,184 @@
# app/routers/home_modules.py
import json
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.home_module import HomeModule
from app.models.admin import Admin
from app.auth import get_current_admin
router = APIRouter()
# Schemas
class ModuleContent(BaseModel):
pass
class ModuleStyleConfig(BaseModel):
pass
class ModuleUpdate(BaseModel):
title: Optional[str] = None
subtitle: Optional[str] = None
content: Optional[dict] = None
style_config: Optional[dict] = None
is_enabled: Optional[bool] = None
sort_order: Optional[int] = None
class ModuleSortItem(BaseModel):
id: int
sort_order: int
class ModuleSortRequest(BaseModel):
orders: List[ModuleSortItem]
class ModuleResponse(BaseModel):
code: int = 200
data: dict | list = None
# Helper functions
def module_to_dict(module: HomeModule) -> dict:
return {
"id": module.id,
"name": module.name,
"code": module.code,
"type": module.type,
"sort_order": module.sort_order,
"is_enabled": module.is_enabled,
"is_fixed": module.is_fixed,
"title": module.title,
"subtitle": module.subtitle,
"content": json.loads(module.content) if module.content else {},
"style_config": json.loads(module.style_config) if module.style_config else {}
}
# Public routes
@router.get("/home/modules", response_model=ModuleResponse)
def get_public_modules(db: Session = Depends(get_db)):
"""Get all enabled home modules for public display"""
modules = db.query(HomeModule).filter(
HomeModule.is_enabled == True
).order_by(HomeModule.sort_order.asc()).all()
return ModuleResponse(data=[module_to_dict(m) for m in modules])
# Admin routes
@router.get("/admin/home/modules", response_model=ModuleResponse)
def get_admin_modules(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Get all home modules for admin management"""
modules = db.query(HomeModule).order_by(HomeModule.sort_order.asc()).all()
# Auto-create missing default modules
existing_types = {m.type for m in modules}
if 'experts' not in existing_types:
expert_module = HomeModule(
name="专家团队",
code="experts",
type="experts",
sort_order=7,
is_enabled=True,
is_fixed=False,
title="专家团队",
content=json.dumps({"view_more_link": ""}),
style_config=json.dumps({"backgroundColor": "#ffffff"})
)
db.add(expert_module)
db.commit()
db.refresh(expert_module)
modules = db.query(HomeModule).order_by(HomeModule.sort_order.asc()).all()
return ModuleResponse(data=[module_to_dict(m) for m in modules])
@router.get("/admin/home/modules/{module_id}", response_model=ModuleResponse)
def get_module_detail(
module_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Get single module detail"""
module = db.query(HomeModule).filter(HomeModule.id == module_id).first()
if not module:
raise HTTPException(status_code=404, detail="Module not found")
return ModuleResponse(data=module_to_dict(module))
@router.put("/admin/home/modules/{module_id}", response_model=ModuleResponse)
def update_module(
module_id: int,
request: ModuleUpdate,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Update module content and settings"""
module = db.query(HomeModule).filter(HomeModule.id == module_id).first()
if not module:
raise HTTPException(status_code=404, detail="Module not found")
if request.title is not None:
module.title = request.title
if request.subtitle is not None:
module.subtitle = request.subtitle
if request.content is not None:
module.content = json.dumps(request.content, ensure_ascii=False)
if request.style_config is not None:
module.style_config = json.dumps(request.style_config, ensure_ascii=False)
if request.is_enabled is not None:
module.is_enabled = request.is_enabled
if request.sort_order is not None:
module.sort_order = request.sort_order
db.commit()
db.refresh(module)
return ModuleResponse(message="更新成功", data=module_to_dict(module))
@router.put("/admin/home/modules/sort", response_model=ModuleResponse)
def update_module_sort(
request: ModuleSortRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Batch update module sort order"""
for item in request.orders:
module = db.query(HomeModule).filter(HomeModule.id == item.id).first()
if module:
module.sort_order = item.sort_order
db.commit()
return ModuleResponse(message="排序更新成功")
@router.put("/admin/home/modules/{module_id}/toggle", response_model=ModuleResponse)
def toggle_module(
module_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Toggle module enabled status"""
module = db.query(HomeModule).filter(HomeModule.id == module_id).first()
if not module:
raise HTTPException(status_code=404, detail="Module not found")
module.is_enabled = not module.is_enabled
db.commit()
return ModuleResponse(
message="状态更新成功",
data={"id": module.id, "is_enabled": module.is_enabled}
)
@@ -0,0 +1,136 @@
# app/routers/home_partners.py
from typing import Union, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.home_partner import HomePartner
from app.models.admin import Admin
from app.auth import get_current_admin
router = APIRouter()
# Schemas
class HomePartnerCreate(BaseModel):
name: str
logo_url: Optional[str] = None
description: Optional[str] = None
website_url: Optional[str] = None
sort_order: int = 0
is_enabled: bool = True
class HomePartnerUpdate(BaseModel):
name: Optional[str] = None
logo_url: Optional[str] = None
description: Optional[str] = None
website_url: Optional[str] = None
sort_order: Optional[int] = None
is_enabled: Optional[bool] = None
class HomePartnerResponse(BaseModel):
code: int = 200
data: Union[dict, list, None] = None
message: str = "success"
# Helper
def partner_to_dict(partner: HomePartner) -> dict:
return {
"id": partner.id,
"name": partner.name,
"logo_url": partner.logo_url,
"description": partner.description,
"website_url": partner.website_url,
"sort_order": partner.sort_order,
"is_enabled": partner.is_enabled,
"created_at": partner.created_at.isoformat() if partner.created_at else None,
"updated_at": partner.updated_at.isoformat() if partner.updated_at else None,
}
# Public routes
@router.get("/home-partners", response_model=HomePartnerResponse)
def get_public_partners(
limit: int = 20,
db: Session = Depends(get_db)
):
"""Get enabled home partners for public display"""
partners = db.query(HomePartner).filter(
HomePartner.is_enabled == True
).order_by(HomePartner.sort_order.asc()).limit(limit).all()
result = [partner_to_dict(p) for p in partners]
return HomePartnerResponse(data=result)
# Admin routes
@router.get("/admin/home-partners", response_model=HomePartnerResponse)
def get_admin_partners(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Get all home partners for admin"""
partners = db.query(HomePartner).order_by(HomePartner.sort_order.asc()).all()
result = [partner_to_dict(p) for p in partners]
return HomePartnerResponse(data=result)
@router.post("/admin/home-partners", response_model=HomePartnerResponse)
def create_partner(
request: HomePartnerCreate,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Create a new home partner"""
partner = HomePartner(
name=request.name,
logo_url=request.logo_url,
description=request.description,
website_url=request.website_url,
sort_order=request.sort_order,
is_enabled=request.is_enabled,
)
db.add(partner)
db.commit()
db.refresh(partner)
return HomePartnerResponse(message="添加成功", data=partner_to_dict(partner))
@router.put("/admin/home-partners/{partner_id}", response_model=HomePartnerResponse)
def update_partner(
partner_id: int,
request: HomePartnerUpdate,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Update home partner"""
partner = db.query(HomePartner).filter(HomePartner.id == partner_id).first()
if not partner:
raise HTTPException(status_code=404, detail="合作伙伴不存在")
for field, value in request.dict(exclude_unset=True).items():
setattr(partner, field, value)
db.commit()
db.refresh(partner)
return HomePartnerResponse(message="更新成功", data=partner_to_dict(partner))
@router.delete("/admin/home-partners/{partner_id}", response_model=HomePartnerResponse)
def delete_partner(
partner_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Delete home partner"""
partner = db.query(HomePartner).filter(HomePartner.id == partner_id).first()
if not partner:
raise HTTPException(status_code=404, detail="合作伙伴不存在")
db.delete(partner)
db.commit()
return HomePartnerResponse(message="删除成功")
@@ -0,0 +1,359 @@
"""
图片资源管理 API 路由
复用自 personal-blog,添加 module 字段支持多模块共用
图片扁平存储在 /uploads 目录下,缩略图在 /uploads/thumbnails/
"""
import os
import re
import uuid
import shutil
from datetime import datetime
from typing import Optional
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from sqlalchemy import or_
from PIL import Image as PILImage
from app.database import get_db
from app.models.image import Image
from app.models.article import Article
from app.routers.auth import get_current_admin
from app.config import get_settings
from app.utils.file_utils import cleanup_file_by_path
router = APIRouter(prefix="/images", tags=["images"], redirect_slashes=False)
# 上传配置 - 使用与静态文件服务相同的目录
settings = get_settings()
UPLOAD_DIR = Path(settings.UPLOAD_DIR)
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
# 缩略图目录
THUMBNAIL_DIR = UPLOAD_DIR / "thumbnails"
THUMBNAIL_DIR.mkdir(exist_ok=True)
# 允许的图片格式
ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
def generate_filename(original_name: str) -> str:
"""生成唯一文件名"""
ext = Path(original_name).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(status_code=400, detail=f"不支持的文件格式,允许: {', '.join(ALLOWED_EXTENSIONS)}")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
unique_id = uuid.uuid4().hex[:8]
return f"{timestamp}_{unique_id}{ext}"
def create_thumbnail(image_path: Path, thumb_path: Path, max_size: int = 300):
"""创建缩略图"""
try:
with PILImage.open(image_path) as img:
# 转换为 RGB(处理透明图片)
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
# 等比缩放到最大尺寸
img.thumbnail((max_size, max_size), PILImage.Resampling.LANCZOS)
# 保存
img.save(thumb_path, "JPEG", quality=85)
return True
except Exception as e:
print(f"创建缩略图失败: {e}")
return False
@router.get("")
@router.get("/")
def list_images(
page: int = Query(default=1, ge=1),
size: int = Query(default=20, ge=1, le=100),
module: Optional[str] = Query(default=None, description="按模块筛选"),
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""获取图片列表(同时支持 /api/images 和 /api/images/)"""
query = db.query(Image).order_by(Image.created_at.desc())
if module:
query = query.filter(Image.module == module)
total = query.count()
images = query.offset((page - 1) * size).limit(size).all()
return {
"items": [img.to_dict() for img in images],
"total": total,
"page": page,
"size": size
}
@router.post("/upload")
async def upload_image(
file: UploadFile = File(...),
description: Optional[str] = Form(default=None),
module: str = Form(default="news"),
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""上传图片 - 扁平存储在 /uploads 目录"""
# 检查文件大小
file_size = 0
content = await file.read()
file_size = len(content)
if file_size > MAX_FILE_SIZE:
raise HTTPException(status_code=400, detail=f"文件大小超过限制 ({MAX_FILE_SIZE / 1024 / 1024}MB)")
# 生成文件名
try:
filename = generate_filename(file.filename)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail="文件名处理失败")
# 扁平存储 - 直接放在 /uploads 目录下
file_path = UPLOAD_DIR / filename
with open(file_path, "wb") as f:
f.write(content)
# 获取图片尺寸
width, height = None, None
try:
with PILImage.open(file_path) as img:
width, height = img.size
except Exception:
pass
# 创建缩略图 - 统一放在 thumbnails 目录下
# 缩略图与原图同名,但扩展名统一为 .jpg
thumbnail_name = f"{Path(filename).stem}.jpg"
thumb_file_path = THUMBNAIL_DIR / thumbnail_name
thumb_relative_path = None
if create_thumbnail(file_path, thumb_file_path):
thumb_relative_path = f"/storage/admin/images/thumbnails/{thumbnail_name}"
# 保存到数据库 - 使用新的存储路径
relative_file_path = f"/storage/admin/images/{filename}"
image = Image(
filename=filename,
original_name=file.filename,
file_path=relative_file_path,
thumb_path=thumb_relative_path,
description=description,
file_size=file_size,
width=width,
height=height,
module=module
)
db.add(image)
db.commit()
db.refresh(image)
return {
"success": True,
"data": image.to_dict()
}
@router.delete("/{image_id}")
def delete_image(
image_id: int,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""删除图片"""
image = db.query(Image).filter(Image.id == image_id).first()
if not image:
raise HTTPException(status_code=404, detail="图片不存在")
# 删除物理文件及其缩略图
cleanup_file_by_path(image.file_path)
db.delete(image)
db.commit()
return {"message": "删除成功"}
@router.put("/{image_id}")
def update_image(
image_id: int,
description: Optional[str] = Form(default=None),
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""更新图片描述"""
image = db.query(Image).filter(Image.id == image_id).first()
if not image:
raise HTTPException(status_code=404, detail="图片不存在")
if description is not None:
image.description = description
db.commit()
db.refresh(image)
return image.to_dict()
@router.get("/{image_id}/insert")
def get_image_insert_info(
image_id: int,
size: str = Query(default="original", pattern="^(original|thumb)$"),
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""获取图片插入信息(用于编辑器插入)"""
image = db.query(Image).filter(Image.id == image_id).first()
if not image:
raise HTTPException(status_code=404, detail="图片不存在")
url = image.file_path if size == "original" else (image.thumb_path or image.file_path)
return {
"id": image.id,
"url": url,
"width": image.width,
"height": image.height,
"original_name": image.original_name
}
@router.post("/batch-delete")
def batch_delete_images(
image_ids: list[int],
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""批量删除图片"""
deleted_count = 0
for image_id in image_ids:
image = db.query(Image).filter(Image.id == image_id).first()
if image:
# 删除物理文件及其缩略图
cleanup_file_by_path(image.file_path)
db.delete(image)
deleted_count += 1
db.commit()
return {"message": f"成功删除 {deleted_count} 张图片"}
def _collect_referenced_paths(db: Session) -> set:
"""收集所有被文章引用的图片路径(封面图 + 内容中的图片)。
优化:仅加载有图片引用的文章,跳过无封面且内容无图片路径的文章。
"""
referenced_paths = set()
# 预过滤:只查有 cover_image 或内容中可能包含图片的文章
articles = db.query(Article).filter(
or_(
Article.cover_image.isnot(None),
Article.cover_image != '',
Article.content.like('%/storage/%'),
Article.content.like('%/uploads/%'),
Article.content.like('%.jpg%'),
Article.content.like('%.png%'),
Article.content.like('%.gif%'),
Article.content.like('%.webp%'),
)
).all()
for post in articles:
if post.cover_image:
referenced_paths.add(post.cover_image)
if post.content:
# Markdown: ![alt](path) 或 <img src="path">
md_images = re.findall(r'!\[.*?\]\((.*?)\)', post.content)
html_images = re.findall(r'<img[^>]+src=["\']([^"\']+)["\']', post.content)
for path in md_images + html_images:
referenced_paths.add(path)
return referenced_paths
@router.get("/unreferenced")
def get_unreferenced_images(
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""扫描未被任何文章引用的图片"""
referenced_paths = _collect_referenced_paths(db)
# 仅获取有 file_path 的图片
all_images = db.query(Image).filter(Image.file_path.isnot(None)).all()
# 找出未引用的图片
unreferenced = []
for img in all_images:
if img.file_path not in referenced_paths:
unreferenced.append(img.to_dict())
return {
"items": unreferenced,
"total": len(unreferenced)
}
@router.post("/cleanup")
def cleanup_unreferenced_images(
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""批量删除未被任何文章引用的图片"""
referenced_paths = _collect_referenced_paths(db)
# 仅获取有 file_path 的图片
all_images = db.query(Image).filter(Image.file_path.isnot(None)).all()
# 删除未引用的图片
deleted_count = 0
deleted_files = []
for img in all_images:
if img.file_path not in referenced_paths:
cleanup_file_by_path(img.file_path)
deleted_files.append(img.to_dict())
db.delete(img)
deleted_count += 1
db.commit()
return {
"message": f"成功清理 {deleted_count} 张未引用图片",
"deleted_count": deleted_count,
"deleted_files": deleted_files
}
# 静态文件服务(开发环境使用,生产环境建议用 Nginx)
@router.get("/file/{path:path}")
def serve_image(path: str):
"""提供图片文件访问"""
file_path = UPLOAD_DIR / path
if not file_path.exists():
raise HTTPException(status_code=404, detail="文件不存在")
# 安全检查,防止目录遍历
try:
file_path.resolve().relative_to(UPLOAD_DIR.resolve())
except ValueError:
raise HTTPException(status_code=403, detail="禁止访问")
return FileResponse(file_path)
@@ -0,0 +1,124 @@
# app/routers/nav_menus.py
from typing import List, Union, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.nav_menu import NavMenu
from app.models.admin import Admin
from app.auth import get_current_admin
router = APIRouter()
# Schemas
class NavMenuCreate(BaseModel):
name: str
link: str
sort_order: int = 0
is_enabled: bool = True
is_external: bool = False
parent_id: Optional[int] = None
class NavMenuUpdate(BaseModel):
name: str = None
link: str = None
sort_order: int = None
is_enabled: bool = None
is_external: bool = None
parent_id: Optional[int] = None
class NavMenuResponse(BaseModel):
code: int = 200
data: Union[dict, list] = None
message: str = "success"
# Helper
def menu_to_dict(menu: NavMenu) -> dict:
return {
"id": menu.id,
"name": menu.name,
"link": menu.link,
"sort_order": menu.sort_order,
"is_enabled": menu.is_enabled,
"is_external": menu.is_external,
"parent_id": menu.parent_id,
"children": [menu_to_dict(c) for c in menu.children if c.is_enabled] if menu.children else []
}
# Public routes
@router.get("/nav", response_model=NavMenuResponse)
def get_public_menus(db: Session = Depends(get_db)):
"""Get enabled nav menus for public display"""
menus = db.query(NavMenu).filter(
NavMenu.is_enabled == True,
NavMenu.parent_id == None
).order_by(NavMenu.sort_order.asc()).all()
return NavMenuResponse(data=[menu_to_dict(m) for m in menus])
# Admin routes
@router.get("/admin/nav", response_model=NavMenuResponse)
def get_admin_menus(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Get all nav menus for admin"""
menus = db.query(NavMenu).order_by(NavMenu.sort_order.asc()).all()
return NavMenuResponse(data=[menu_to_dict(m) for m in menus])
@router.post("/admin/nav", response_model=NavMenuResponse)
def create_menu(
request: NavMenuCreate,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Create new nav menu"""
menu = NavMenu(**request.dict())
db.add(menu)
db.commit()
db.refresh(menu)
return NavMenuResponse(message="创建成功", data=menu_to_dict(menu))
@router.put("/admin/nav/{menu_id}", response_model=NavMenuResponse)
def update_menu(
menu_id: int,
request: NavMenuUpdate,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Update nav menu"""
menu = db.query(NavMenu).filter(NavMenu.id == menu_id).first()
if not menu:
raise HTTPException(status_code=404, detail="Menu not found")
for field, value in request.dict(exclude_unset=True).items():
setattr(menu, field, value)
db.commit()
db.refresh(menu)
return NavMenuResponse(message="更新成功", data=menu_to_dict(menu))
@router.delete("/admin/nav/{menu_id}", response_model=NavMenuResponse)
def delete_menu(
menu_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Delete nav menu"""
menu = db.query(NavMenu).filter(NavMenu.id == menu_id).first()
if not menu:
raise HTTPException(status_code=404, detail="Menu not found")
db.delete(menu)
db.commit()
return NavMenuResponse(message="删除成功")
@@ -0,0 +1,574 @@
# ⚠️ 此新闻模块已废弃,不再使用。
# 已被「栏目管理 + 内容管理」(columns.py + articles.py)取代。
# 请勿删除此注释,供后续维护人员参考。
"""
新闻资讯 API 路由(已废弃)
包含栏目管理和资讯管理
"""
import html
import os
import re
import shutil
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import func
from pydantic import BaseModel, Field
from pypinyin import lazy_pinyin
def extract_summary(content: str, max_length: int = 100) -> str:
"""从 HTML/Markdown 内容中提取纯文本摘要"""
if not content:
return ""
# 去除 HTML 标签
text = re.sub(r'<[^>]+>', '', content)
# 解码 HTML 实体(如 &quot; &nbsp; &lt; 等)
text = html.unescape(text)
# 去除 Markdown 标记(简化处理)
text = re.sub(r'[#*_`~\[\]\(\)!>-]', '', text)
# 去除多余空白
text = re.sub(r'\s+', ' ', text).strip()
# 截取摘要
if len(text) > max_length:
return text[:max_length] + "..."
return text
from app.models.news_category import NewsCategory
from app.models.news_post import NewsPost
from app.models.image import Image
from app.routers.auth import get_current_admin
from app.utils.file_utils import cleanup_file_by_path
from app.database import get_db
router = APIRouter(prefix="/news", tags=["news"])
def generate_slug(text: str) -> str:
"""将中文标题转为拼音 slug"""
if not text:
return ''
# 转拼音,无音调
py = lazy_pinyin(text, style=0)
slug = '-'.join(py)
# 清理特殊字符
slug = re.sub(r'[^a-zA-Z0-9-]', '', slug).lower()
# 去重横线
slug = re.sub(r'-+', '-', slug).strip('-')
return slug or f'post-{int(__import__("time").time())}'
# ========== Pydantic 模型 ==========
class CategoryCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=50)
slug: Optional[str] = Field(None, max_length=50)
description: Optional[str] = Field(None, max_length=200)
sort_order: int = Field(default=0)
is_enabled: bool = Field(default=True)
class CategoryUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=50)
slug: Optional[str] = Field(None, min_length=1, max_length=50)
description: Optional[str] = Field(None, max_length=200)
sort_order: Optional[int] = None
is_enabled: Optional[bool] = None
class PostCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
slug: Optional[str] = Field(None, max_length=200)
content: str = Field(..., min_length=1)
content_type: str = Field(default="markdown", pattern="^(markdown|html)$")
summary: Optional[str] = Field(None, max_length=500)
cover_image: Optional[str] = Field(None, max_length=500)
category_id: int
is_draft: bool = Field(default=True)
class PostUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=200)
slug: Optional[str] = Field(None, min_length=1, max_length=200)
content: Optional[str] = Field(None, min_length=1)
content_type: Optional[str] = Field(None, pattern="^(markdown|html)$")
summary: Optional[str] = Field(None, max_length=500)
cover_image: Optional[str] = Field(None, max_length=500)
category_id: Optional[int] = None
is_draft: Optional[bool] = None
class PostPublish(BaseModel):
is_published: bool
# ========== 临时图片迁移辅助函数 ==========
TEMP_STORAGE_PREFIX = "/storage/tmp/crawl"
def get_upload_dir():
"""获取上传目录路径"""
from app.config import get_settings
settings = get_settings()
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
return os.path.join(base_dir, settings.UPLOAD_DIR)
def get_crawl_temp_dir():
"""获取采集临时目录 - 跨平台"""
if os.name == 'nt': # Windows
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
return os.path.join(base_dir, 'storage', 'tmp', 'crawl')
return '/tmp/news_crawl'
def migrate_temp_images(content: str, cover_image: Optional[str], db: Session):
"""
将内容中的临时图片迁移到正式目录,并写入 images 表。
返回更新后的 (content, cover_image)。
"""
upload_dir = get_upload_dir()
temp_dir = get_crawl_temp_dir()
# 收集所有需要迁移的临时路径
temp_paths = set()
if cover_image and cover_image.startswith(TEMP_STORAGE_PREFIX):
temp_paths.add(cover_image)
# 从 content 中查找所有临时图片路径
pattern = re.escape(TEMP_STORAGE_PREFIX) + r'/[^\\)"\'>\s]+'
for match in re.finditer(pattern, content):
temp_paths.add(match.group())
if not temp_paths:
return content, cover_image
# 路径映射: 临时路径 -> 正式路径
path_mapping = {}
for temp_path in temp_paths:
filename = os.path.basename(temp_path)
temp_file = os.path.join(temp_dir, filename)
if not os.path.exists(temp_file):
continue
# 移动到正式目录
dest_file = os.path.join(upload_dir, filename)
shutil.move(temp_file, dest_file)
# 迁移缩略图
thumb_filename = f"{os.path.splitext(filename)[0]}.jpg"
temp_thumb = os.path.join(temp_dir, "thumbnails", thumb_filename)
dest_thumb = os.path.join(upload_dir, "thumbnails", thumb_filename)
if os.path.exists(temp_thumb):
shutil.move(temp_thumb, dest_thumb)
formal_path = f"/storage/admin/images/{filename}"
formal_thumb = f"/storage/admin/images/thumbnails/{thumb_filename}"
path_mapping[temp_path] = formal_path
# 写入 images 表
try:
file_size = os.path.getsize(dest_file)
except OSError:
file_size = 0
existing = db.query(Image).filter(Image.file_path == formal_path).first()
if not existing:
img_record = Image(
filename=filename,
original_name=filename,
file_path=formal_path,
thumb_path=formal_thumb,
file_size=file_size,
module="news"
)
db.add(img_record)
# 替换 content 中的路径
new_content = content
for temp_path, formal_path in path_mapping.items():
new_content = new_content.replace(temp_path, formal_path)
# 替换 cover_image
new_cover = cover_image
if cover_image and cover_image in path_mapping:
new_cover = path_mapping[cover_image]
return new_content, new_cover
# ========== 栏目管理 API ==========
@router.get("/categories")
def list_categories(
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
include_count: bool = Query(default=True, description="是否包含文章数量")
):
"""获取栏目列表"""
query = db.query(NewsCategory).order_by(NewsCategory.sort_order.asc())
categories = query.all()
# 获取每个栏目的文章数量
if include_count:
counts = db.query(
NewsPost.category_id,
func.count(NewsPost.id).label("count")
).group_by(NewsPost.category_id).all()
count_map = {c.category_id: c.count for c in counts}
for cat in categories:
cat.post_count = count_map.get(cat.id, 0)
return {
"items": [cat.to_dict(include_post_count=include_count) for cat in categories],
"total": len(categories)
}
@router.post("/categories")
def create_category(
data: CategoryCreate,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""创建栏目"""
# 自动生成 slug(如果未提供)
slug = data.slug or generate_slug(data.name)
# 检查 slug 是否已存在
existing = db.query(NewsCategory).filter(NewsCategory.slug == slug).first()
if existing:
raise HTTPException(status_code=400, detail="栏目标识已存在")
category = NewsCategory(**{**data.model_dump(), "slug": slug})
db.add(category)
db.commit()
db.refresh(category)
return category.to_dict()
@router.get("/categories/{category_id}")
def get_category(
category_id: int,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""获取栏目详情"""
category = db.query(NewsCategory).filter(NewsCategory.id == category_id).first()
if not category:
raise HTTPException(status_code=404, detail="栏目不存在")
return category.to_dict(include_post_count=True)
@router.put("/categories/{category_id}")
def update_category(
category_id: int,
data: CategoryUpdate,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""更新栏目"""
category = db.query(NewsCategory).filter(NewsCategory.id == category_id).first()
if not category:
raise HTTPException(status_code=404, detail="栏目不存在")
# 检查 slug 是否冲突
if data.slug and data.slug != category.slug:
existing = db.query(NewsCategory).filter(NewsCategory.slug == data.slug).first()
if existing:
raise HTTPException(status_code=400, detail="栏目标识已存在")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(category, key, value)
db.commit()
db.refresh(category)
return category.to_dict()
@router.delete("/categories/{category_id}")
def delete_category(
category_id: int,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""删除栏目"""
category = db.query(NewsCategory).filter(NewsCategory.id == category_id).first()
if not category:
raise HTTPException(status_code=404, detail="栏目不存在")
# 检查是否有文章使用此栏目
post_count = db.query(NewsPost).filter(NewsPost.category_id == category_id).count()
if post_count > 0:
raise HTTPException(status_code=400, detail="该栏目下还有文章,无法删除")
db.delete(category)
db.commit()
return {"message": "删除成功"}
# ========== 资讯管理 API ==========
@router.get("/posts")
def list_posts(
page: int = Query(default=1, ge=1),
size: int = Query(default=10, ge=1, le=100),
category_id: Optional[int] = Query(default=None),
keyword: Optional[str] = Query(default=None),
status: Optional[str] = Query(default=None, pattern="^(published|draft|all)$"),
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""获取资讯列表"""
query = db.query(NewsPost).options(joinedload(NewsPost.category))
# 筛选条件
if category_id:
query = query.filter(NewsPost.category_id == category_id)
if keyword:
query = query.filter(NewsPost.title.contains(keyword))
if status == "published":
query = query.filter(NewsPost.is_published == True)
elif status == "draft":
query = query.filter(NewsPost.is_draft == True)
# 排序:先按创建时间倒序
query = query.order_by(NewsPost.created_at.desc())
# 分页
total = query.count()
posts = query.offset((page - 1) * size).limit(size).all()
return {
"items": [post.to_dict(include_content=False) for post in posts],
"total": total,
"page": page,
"size": size
}
@router.post("/posts")
def create_post(
data: PostCreate,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""创建资讯"""
# 自动生成 slug(如果未提供)
slug = data.slug or generate_slug(data.title)
# 检查 slug 是否已存在
existing = db.query(NewsPost).filter(NewsPost.slug == slug).first()
if existing:
raise HTTPException(status_code=400, detail="文章标识已存在")
# 检查栏目是否存在
category = db.query(NewsCategory).filter(NewsCategory.id == data.category_id).first()
if not category:
raise HTTPException(status_code=404, detail="栏目不存在")
# 自动生成摘要(如果未提供)
summary = data.summary
if not summary:
summary = extract_summary(data.content, max_length=100)
# 迁移临时图片到正式目录
migrated_content, migrated_cover = migrate_temp_images(data.content, data.cover_image, db)
post = NewsPost(
title=data.title,
slug=slug,
content=migrated_content,
content_type=data.content_type,
summary=summary,
cover_image=migrated_cover,
category_id=data.category_id,
is_draft=data.is_draft,
is_published=not data.is_draft
)
db.add(post)
db.commit()
db.refresh(post)
return post.to_dict(include_content=True)
@router.get("/posts/{post_id}")
def get_post(
post_id: int,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""获取资讯详情"""
post = db.query(NewsPost).options(joinedload(NewsPost.category)).filter(NewsPost.id == post_id).first()
if not post:
raise HTTPException(status_code=404, detail="文章不存在")
return post.to_dict(include_content=True)
@router.put("/posts/{post_id}")
def update_post(
post_id: int,
data: PostUpdate,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""更新资讯"""
post = db.query(NewsPost).filter(NewsPost.id == post_id).first()
if not post:
raise HTTPException(status_code=404, detail="文章不存在")
# 检查 slug 是否冲突(如果提供了新的 slug)
slug = data.slug
if slug is not None:
if not slug.strip():
slug = generate_slug(data.title or post.title)
if slug != post.slug:
existing = db.query(NewsPost).filter(NewsPost.slug == slug).first()
if existing:
raise HTTPException(status_code=400, detail="文章标识已存在")
# 检查栏目是否存在
if data.category_id:
category = db.query(NewsCategory).filter(NewsCategory.id == data.category_id).first()
if not category:
raise HTTPException(status_code=404, detail="栏目不存在")
update_data = data.model_dump(exclude_unset=True)
if slug is not None:
update_data['slug'] = slug
# 如果摘要被清空,自动从内容重新提取
if "summary" in update_data and not update_data["summary"].strip():
content_to_extract = update_data.get("content") or post.content
if content_to_extract:
update_data["summary"] = extract_summary(content_to_extract, max_length=100)
# 处理草稿/发布状态联动
if "is_draft" in update_data:
update_data["is_published"] = not update_data["is_draft"]
for key, value in update_data.items():
setattr(post, key, value)
db.commit()
db.refresh(post)
return post.to_dict(include_content=True)
@router.delete("/posts/{post_id}")
def delete_post(
post_id: int,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""删除资讯"""
post = db.query(NewsPost).filter(NewsPost.id == post_id).first()
if not post:
raise HTTPException(status_code=404, detail="文章不存在")
# 删除封面图及其缩略图
if post.cover_image:
cleanup_file_by_path(post.cover_image)
db.delete(post)
db.commit()
return {"message": "删除成功"}
@router.post("/posts/{post_id}/publish")
def publish_post(
post_id: int,
data: PostPublish,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""发布/取消发布资讯"""
post = db.query(NewsPost).filter(NewsPost.id == post_id).first()
if not post:
raise HTTPException(status_code=404, detail="文章不存在")
post.is_published = data.is_published
post.is_draft = not data.is_published
db.commit()
db.refresh(post)
return post.to_dict()
# ========== 访客端 API(无需认证)==========
@router.get("/public/posts")
def list_public_posts(
page: int = Query(default=1, ge=1),
size: int = Query(default=10, ge=1, le=50),
category_slug: Optional[str] = Query(default=None),
db: Session = Depends(get_db)
):
"""访客端:获取已发布的资讯列表(仅显示 is_enabled=True 栏目下的文章)"""
query = db.query(NewsPost).options(joinedload(NewsPost.category)).join(
NewsCategory, NewsPost.category_id == NewsCategory.id
).filter(
NewsPost.is_published == True,
NewsCategory.is_enabled == True
)
# 按栏目筛选
if category_slug:
category = db.query(NewsCategory).filter(NewsCategory.slug == category_slug).first()
if category:
query = query.filter(NewsPost.category_id == category.id)
query = query.order_by(NewsPost.created_at.desc())
total = query.count()
posts = query.offset((page - 1) * size).limit(size).all()
return {
"items": [post.to_dict(include_content=False) for post in posts],
"total": total,
"page": page,
"size": size
}
@router.get("/public/posts/{slug}")
def get_public_post(
slug: str,
db: Session = Depends(get_db)
):
"""访客端:通过 slug 获取文章详情"""
post = db.query(NewsPost).options(joinedload(NewsPost.category)).filter(
NewsPost.slug == slug,
NewsPost.is_published == True
).first()
if not post:
raise HTTPException(status_code=404, detail="文章不存在")
# 增加浏览次数
post.view_count += 1
db.commit()
return post.to_dict(include_content=True)
@router.get("/public/categories")
def list_public_categories(
db: Session = Depends(get_db)
):
"""访客端:获取启用的栏目列表"""
categories = db.query(NewsCategory).filter(
NewsCategory.is_enabled == True
).order_by(NewsCategory.sort_order.asc()).all()
return {
"data": {
"items": [cat.to_dict() for cat in categories],
"total": len(categories)
}
}
@@ -0,0 +1,93 @@
# app/routers/settings.py
from typing import Dict
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.setting import Setting
from app.models.admin import Admin
from app.auth import get_current_admin
router = APIRouter()
# Public settings keys that can be accessed without auth
PUBLIC_SETTINGS = [
"site_name", "site_logo", "site_favicon", "site_description",
"contact_phones", "contact_emails", "contact_address",
"social_links", "friend_links",
"copyright", "bottom_links",
"privacy_url", "terms_url", "sitemap_url",
"seo_title", "seo_description", "seo_keywords"
]
# Schemas
class SettingsUpdate(BaseModel):
settings: Dict[str, str]
class SettingsResponse(BaseModel):
code: int = 200
data: dict = None
message: str = "success"
# Helper
def get_setting_value(db: Session, key: str) -> str:
setting = db.query(Setting).filter(Setting.key == key).first()
return setting.value if setting else None
def set_setting_value(db: Session, key: str, value: str):
setting = db.query(Setting).filter(Setting.key == key).first()
if setting:
setting.value = value
else:
setting = Setting(key=key, value=value)
db.add(setting)
# Public routes
@router.get("/settings", response_model=SettingsResponse)
def get_public_settings(db: Session = Depends(get_db)):
"""Get public settings"""
result = {}
for key in PUBLIC_SETTINGS:
value = get_setting_value(db, key)
if value:
result[key] = value
return SettingsResponse(data=result)
# Admin routes
@router.get("/admin/settings", response_model=SettingsResponse)
def get_all_settings(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Get all settings"""
settings = db.query(Setting).all()
result = {}
for s in settings:
result[s.key] = {
"value": s.value,
"group": s.group,
"description": s.description
}
return SettingsResponse(data=result)
@router.put("/admin/settings", response_model=SettingsResponse)
def update_settings(
request: SettingsUpdate,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin)
):
"""Batch update settings"""
for key, value in request.settings.items():
set_setting_value(db, key, value)
db.commit()
return SettingsResponse(message="设置更新成功")
@@ -0,0 +1,122 @@
# app/routers/upload.py
import os
import uuid
from datetime import datetime
from pathlib import Path
from PIL import Image as PILImage
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Form
from pydantic import BaseModel
from app.config import get_settings
from app.models.admin import Admin
from app.auth import get_current_admin
router = APIRouter()
settings = get_settings()
# Allowed file types (MIME)
ALLOWED_IMAGE_TYPES = {
"image/jpeg", "image/png", "image/gif", "image/webp",
"image/x-icon", "image/vnd.microsoft.icon", # .ico favicon
"image/svg+xml", # .svg
}
# Allowed extensions (fallback when browser reports wrong MIME)
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".ico", ".svg"}
MAX_FILE_SIZE = settings.MAX_UPLOAD_SIZE # 5MB
# Schemas
class UploadResponse(BaseModel):
code: int = 200
data: dict = None
message: str = "success"
def create_thumbnail(image_path: Path, thumb_path: Path, max_size: int = 300):
"""创建缩略图"""
try:
with PILImage.open(image_path) as img:
# 转换为 RGB(处理透明图片)
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
# 等比缩放到最大尺寸
img.thumbnail((max_size, max_size), PILImage.Resampling.LANCZOS)
# 保存
img.save(thumb_path, "JPEG", quality=85)
return True
except Exception as e:
print(f"创建缩略图失败: {e}")
return False
@router.post("/upload/image", response_model=UploadResponse)
async def upload_image(
file: UploadFile = File(...),
subdir: str = Form(default=""),
current_admin = Depends(get_current_admin)
):
"""Upload image file - 支持按子目录存储"""
# Check file type - allow by MIME or by extension (browsers may report wrong MIME for .ico)
file_ext = os.path.splitext(file.filename or "")[1].lower()
if file.content_type not in ALLOWED_IMAGE_TYPES and file_ext not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"不支持的文件类型: {file.content_type} ({file_ext})"
)
# Check file size
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(
status_code=400,
detail=f"文件大小超过限制: {MAX_FILE_SIZE / 1024 / 1024}MB"
)
# Generate unique filename with timestamp
ext = os.path.splitext(file.filename)[1].lower()
if not ext:
ext = ".jpg" if file.content_type == "image/jpeg" else ".png"
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{timestamp}_{uuid.uuid4().hex[:8]}{ext}"
# 根据子目录存储
upload_path = Path(settings.UPLOAD_DIR)
if subdir:
upload_path = upload_path / subdir
upload_path.mkdir(parents=True, exist_ok=True)
# 缩略图目录(始终放在 thumbnails 下)
thumbnail_dir = Path(settings.UPLOAD_DIR) / "thumbnails"
thumbnail_dir.mkdir(exist_ok=True)
# Save file
file_path = upload_path / filename
with open(file_path, "wb") as f:
f.write(content)
# 创建缩略图(.ico 和 .svg 跳过缩略图生成)
thumbnail_name = f"{Path(filename).stem}.jpg"
thumb_path = thumbnail_dir / thumbnail_name
thumb_relative_path = None
if ext not in ('.ico', '.svg') and create_thumbnail(file_path, thumb_path):
thumb_relative_path = f"/storage/admin/images/thumbnails/{thumbnail_name}"
# Return URL
if subdir:
url = f"/storage/admin/images/{subdir}/{filename}"
else:
url = f"/storage/admin/images/{filename}"
return UploadResponse(
message="上传成功",
data={
"url": url,
"filename": filename,
"size": len(content),
"thumb_url": thumb_relative_path
}
)
@@ -0,0 +1,530 @@
"""
微信公众号文章采集路由
使用 Playwright + Stealth 采集微信公众号文章内容
"""
import os
import re
import uuid
import json
import asyncio
import requests
from datetime import datetime
from typing import Optional, List
from urllib.parse import urljoin, urlparse
from fastapi import APIRouter, Depends, HTTPException, Body
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from bs4 import BeautifulSoup, NavigableString
# 异步 Playwright 支持
try:
import nest_asyncio
nest_asyncio.apply()
except ImportError:
pass
try:
from playwright.async_api import async_playwright
PLAYWRIGHT_AVAILABLE = True
except ImportError:
PLAYWRIGHT_AVAILABLE = False
try:
import markdownify
MARKDOWNIFY_AVAILABLE = True
except ImportError:
MARKDOWNIFY_AVAILABLE = False
# 尝试导入 Stealth 插件
try:
from playwright_stealth import stealth_async
STEALTH_AVAILABLE = True
except ImportError:
STEALTH_AVAILABLE = False
from app.database import SessionLocal, get_db
from app.models.article import Article
from app.models.column import ColumnModel
from app.models.image import Image
from app.routers.auth import get_current_admin
from app.config import get_settings
settings = get_settings()
router = APIRouter(tags=["公众号采集"])
# ========== Pydantic 模型 ==========
class WechatPreviewRequest(BaseModel):
url: str = Field(..., min_length=1, description="微信公众号文章URL(支持 mp.weixin.qq.com 和 weixin.sogou.com)")
class WechatPreviewResponse(BaseModel):
code: int = 200
message: str = "success"
data: Optional[dict] = None
class WechatSaveRequest(BaseModel):
url: str = Field(..., description="原始URL")
title: str = Field(..., min_length=1, max_length=200, description="标题")
author: Optional[str] = Field(None, max_length=50, description="作者")
summary: Optional[str] = Field(None, max_length=500, description="摘要")
cover_image: Optional[str] = Field(None, description="封面图片URL")
content: str = Field(..., description="Markdown内容")
images: List[str] = Field(default=[], description="文章中的图片URL列表")
column_id: int = Field(..., description="所属栏目ID")
source_url: Optional[str] = Field(None, description="来源URL(用于去重)")
class WechatSaveResponse(BaseModel):
code: int = 200
message: str = "success"
data: Optional[dict] = None
# ========== 核心工具函数 ==========
def fix_wechat_html(html: str) -> str:
"""修复微信公众号文章的HTML"""
soup = BeautifulSoup(html, 'html.parser')
# 修复图片:微信用 data-src 存真实URL,src 常为占位图
for img in soup.find_all('img'):
data_src = img.get('data-src', '')
src = img.get('src', '')
# 只要 data-src 是真实http图片URL,且 src 不是有效http图片,就替换
if data_src and data_src.startswith('http') and (
not src or src.startswith('data:image') or not src.startswith('http')
):
img['src'] = data_src
if 'data-src' in img.attrs:
del img['data-src']
# 修复微信代码块:<pre class="code-snippet__js"> 内多个 span 行
for pre in soup.find_all('pre', class_=re.compile('code-snippet')):
code_tag = pre.find('code')
if code_tag:
lines = []
for span in code_tag.find_all('span', class_='code-snippet__line'):
line_text = span.get_text(separator='', strip=False)
if line_text:
lines.append(line_text)
if lines:
code_tag.clear()
for line in lines:
code_tag.append(NavigableString(line + '\n'))
# 移除空标签(保留包含图片的标签)
for tag in soup.find_all(['p', 'div', 'section']):
if tag.get_text(strip=True) == '' and not tag.find_all('img'):
tag.decompose()
return str(soup)
def html_to_markdown(html: str) -> str:
"""将微信HTML转换为Markdown"""
if not MARKDOWNIFY_AVAILABLE:
return html
try:
md = markdownify.markdownify(
html,
heading_style="ATX",
bullets="-",
strip=["script", "style"],
code_language="",
)
# 清理多余空行
md = re.sub(r'\n{3,}', '\n\n', md)
return md.strip()
except Exception:
return html
async def crawl_wechat_article(url: str) -> dict:
"""使用 Playwright 采集微信公众号文章"""
if not PLAYWRIGHT_AVAILABLE:
raise HTTPException(status_code=400, detail="Playwright 未安装,无法采集公众号文章")
result = {"title": "", "content": "", "author": "", "summary": ""}
result["images"] = []
result["cover_image"] = ""
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=[
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-blink-features=AutomationControlled",
"--disable-web-security",
"--disable-features=IsolateOrigins,site-per-process",
],
)
context = await browser.new_context(
viewport={"width": 1920, "height": 1080},
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
),
locale="zh-CN",
)
# 尝试应用 Stealth
try:
page = await context.new_page()
if STEALTH_AVAILABLE:
await stealth_async(page)
except Exception:
page = await context.new_page()
try:
# 搜狗链接需要先激活会话(搜索一次获取 cookies),否则会被反爬拦截
is_sogou = "weixin.sogou.com" in url
if is_sogou:
await page.goto(
"https://weixin.sogou.com/weixin?type=2&query=test",
wait_until="networkidle", timeout=30000,
)
await asyncio.sleep(2)
await page.goto(url, wait_until="networkidle", timeout=30000)
await asyncio.sleep(3)
# 微信文章常有的弹窗
try:
await page.wait_for_selector("#js_pc_qr_code_img", timeout=5000)
# 有二维码,尝试取消
close_btn = await page.query_selector(".js_pc_qr_code_close_btn, .weui-desktop-media__tips-close")
if close_btn:
await close_btn.click()
await asyncio.sleep(2)
except Exception:
pass
# 获取页面标题
title = await page.title()
if title:
title = title.strip()
# 处理微信的 "页面标题 - 文章" 格式
if " - " in title:
title = title.split(" - ")[0]
result["title"] = title
# 尝试获取 HTML 内容
html = await page.content()
# 提取微信公众号的正文
content_html = ""
# 新版微信文章
content_elem = await page.query_selector("#js_content")
if not content_elem:
content_elem = await page.query_selector(".rich_media_content")
if not content_elem:
content_elem = await page.query_selector("#js_article")
if not content_elem:
# 通用:取 body 内容
body = await page.query_selector("body")
if body:
content_html = await body.inner_html()
else:
content_html = await content_elem.inner_html()
if content_html:
content_html = fix_wechat_html(content_html)
result["content"] = html_to_markdown(content_html)
result["raw_html"] = content_html
# 提取作者
try:
author_elem = await page.query_selector(
"#js_name, .rich_media_meta_nickname, .profile_nickname, .rich_media_meta_text"
)
if not author_elem:
author_elem = await page.query_selector(
# 备用选择器
"[id$='_name'], [class*='_name'], .wx_article_author"
)
if author_elem:
author = await author_elem.inner_text()
result["author"] = author.strip()
except Exception:
pass
# 提取摘要/描述
try:
desc_elem = await page.query_selector(
"#js_description, .rich_media_meta_desc, [class*='_desc'], meta[name='description']"
)
if desc_elem:
desc = await desc_elem.inner_text()
result["summary"] = desc.strip()
except Exception:
pass
# 提取封面图片
try:
# 尝试从 meta 标签获取
cover_meta = await page.query_selector('meta[property="og:image"]')
if cover_meta:
cover_url = await cover_meta.get_attribute("content")
if cover_url:
result["cover_image"] = cover_url
if not result["cover_image"]:
cover_img = await page.query_selector(
"#js_cover, .rich_media_cover img, .cover_img, img.cover"
)
if cover_img:
cover_url = await cover_img.get_attribute("src") or await cover_img.get_attribute("data-src")
if cover_url:
result["cover_image"] = cover_url
except Exception:
pass
# 提取文章中所有图片
try:
img_tags = await page.query_selector_all("#js_content img, .rich_media_content img")
img_urls = set()
for img in img_tags:
src = await img.get_attribute("src") or await img.get_attribute("data-src")
if src and ("mmbiz.qpic.cn" in src or src.startswith("http")):
img_urls.add(src)
result["images"] = list(img_urls)
except Exception:
pass
await browser.close()
return result
except Exception as e:
await browser.close()
raise HTTPException(
status_code=500,
detail=f"微信文章采集失败: {str(e)}"
)
def download_image(url: str, save_dir: str) -> Optional[str]:
"""下载图片到本地"""
try:
if not url or not url.startswith("http"):
return url
# 生成唯一文件名
ext = ".jpg"
url_lower = url.lower()
if ".png" in url_lower:
ext = ".png"
elif ".gif" in url_lower:
ext = ".gif"
elif ".webp" in url_lower:
ext = ".webp"
elif ".jpeg" in url_lower or ".jpg" in url_lower:
ext = ".jpg"
filename = f"{uuid.uuid4().hex}{ext}"
filepath = os.path.join(save_dir, filename)
# 创建目录
os.makedirs(save_dir, exist_ok=True)
# 下载
headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
),
"Referer": "https://mp.weixin.qq.com/",
}
resp = requests.get(url, headers=headers, timeout=30, stream=True)
if resp.status_code == 200:
with open(filepath, "wb") as f:
for chunk in resp.iter_content(chunk_size=8192):
f.write(chunk)
return filename
return None
except Exception as e:
print(f"下载图片失败 {url}: {e}")
return None
# ========== API 端点 ==========
@router.post("/wechat-crawl/preview", response_model=WechatPreviewResponse)
async def wechat_preview(
request: WechatPreviewRequest,
admin: dict = Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""预览微信公众号文章(支持 mp.weixin.qq.com 和 weixin.sogou.com 链接)"""
if not (request.url.startswith("https://mp.weixin.qq.com/") or
request.url.startswith("https://weixin.sogou.com/")):
raise HTTPException(status_code=400, detail="请输入正确的微信公众号文章链接")
result = await crawl_wechat_article(request.url)
# 生成摘要(取内容前200个字符)
if not result.get("summary") and result.get("content"):
content_text = re.sub(r'[#*`\[\]()>\-\n]', '', result["content"])
result["summary"] = content_text[:200].strip()
# 获取栏目列表供前端选择
columns = db.query(ColumnModel).filter(
ColumnModel.is_enabled == True
).order_by(ColumnModel.sort_order).all()
return WechatPreviewResponse(
code=200,
message="success",
data={
"title": result.get("title", ""),
"author": result.get("author", ""),
"summary": result.get("summary", ""),
"cover_image": result.get("cover_image", ""),
"content": result.get("content", ""),
"images": result.get("images", []),
"columns": [c.to_dict() for c in columns],
}
)
@router.post("/wechat-crawl/save", response_model=WechatSaveResponse)
async def wechat_save(
request: WechatSaveRequest,
admin: dict = Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""保存采集的微信公众号文章"""
# 验证栏目
column = db.query(ColumnModel).filter(
ColumnModel.id == request.column_id,
ColumnModel.is_enabled == True
).first()
if not column:
raise HTTPException(status_code=400, detail="所选栏目不存在或已禁用")
# 准备图片存储目录(与 crawl 模块保持一致,使用项目根 storage/admin/images/YYYYMM)
settings_obj = get_settings()
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
upload_dir = os.path.join(base_dir, settings_obj.UPLOAD_DIR)
# 按年月创建子目录
year_month = datetime.now().strftime("%Y%m")
upload_dir = os.path.join(upload_dir, year_month)
os.makedirs(upload_dir, exist_ok=True)
# 下载封面图
local_cover = None
if request.cover_image:
filename = download_image(request.cover_image, upload_dir)
if filename:
local_cover = f"/storage/admin/images/{year_month}/{filename}"
# 添加到 Image 表
try:
img = Image(
filename=filename,
original_name=filename,
file_path=f"/storage/admin/images/{year_month}/{filename}",
module="wechat",
)
db.add(img)
db.flush()
except Exception:
pass
# 下载内容中的图片并替换URL
content = request.content
image_urls_to_download = set()
# 从 markdown 中找到所有图片 URL
img_pattern = re.compile(r'!\[.*?\]\((https?://[^\s)]+)\)')
for match in img_pattern.finditer(content):
img_url = match.group(1)
image_urls_to_download.add(img_url)
# 也包括请求中提供的图片列表
for img_url in request.images:
if img_url.startswith("http"):
image_urls_to_download.add(img_url)
# 下载并替换
url_map = {}
for img_url in image_urls_to_download:
filename = download_image(img_url, upload_dir)
if filename:
local_path = f"/storage/admin/images/{year_month}/{filename}"
url_map[img_url] = local_path
# 添加到 Image 表
try:
img = Image(
filename=filename,
original_name=filename,
file_path=f"/storage/admin/images/{year_month}/{filename}",
module="wechat",
)
db.add(img)
db.flush()
except Exception:
pass
# 替换 Markdown 中的图片URL
for old_url, local_path in url_map.items():
content = content.replace(
f"]({old_url})",
f"]({local_path})"
)
# 生成 slug
def generate_slug(text: str) -> str:
try:
from pypinyin import lazy_pinyin
py = lazy_pinyin(text, style=0)
slug = "-".join(py)
slug = re.sub(r"[^a-zA-Z0-9-]", "", slug).lower()
slug = re.sub(r"-+", "-", slug).strip("-")
return slug or f"article-{int(datetime.now().timestamp())}"
except ImportError:
import hashlib
return hashlib.md5(text.encode()).hexdigest()[:12]
slug = generate_slug(request.title)
# 检查 slug 唯一性
existing = db.query(Article).filter(Article.slug == slug).first()
if existing:
slug = f"{slug}-{int(datetime.now().timestamp())}"
# 创建文章
article = Article(
title=request.title,
slug=slug,
content=content,
content_type="markdown",
summary=request.summary or "",
cover_image=local_cover,
column_id=request.column_id,
author=request.author or "",
status="draft",
source_url=request.source_url or request.url,
)
db.add(article)
db.commit()
db.refresh(article)
return WechatSaveResponse(
code=200,
message="公众号文章采集并保存成功",
data=article.to_dict(include_content=True)
)
@router.post("/wechat-crawl/columns")
async def get_columns(
admin: dict = Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取栏目列表(供前端下拉选择)"""
columns = db.query(ColumnModel).filter(
ColumnModel.is_enabled == True
).order_by(ColumnModel.sort_order).all()
return {
"code": 200,
"data": [c.to_dict() for c in columns]
}
@@ -0,0 +1,4 @@
# Utils package
from .file_utils import cleanup_file, cleanup_file_by_path
__all__ = ["cleanup_file", "cleanup_file_by_path"]
@@ -0,0 +1,101 @@
"""
文件工具函数
提供统一的文件删除功能,自动处理缩略图同步删除
"""
import os
from pathlib import Path
from app.config import get_settings
settings = get_settings()
# 文件系统路径(从配置读取)
UPLOAD_DIR = Path(settings.UPLOAD_DIR)
THUMBNAIL_DIR = UPLOAD_DIR / "thumbnails"
# URL 路径
STORAGE_URL = "/storage/admin/images"
THUMBNAIL_URL = f"{STORAGE_URL}/thumbnails"
def cleanup_file(file_path: str | Path) -> bool:
"""
删除文件并同步删除对应的缩略图
Args:
file_path: 文件路径(相对 /uploads 的路径、/storage/admin/images/ 格式或绝对路径)
Returns:
bool: 是否成功删除原文件
"""
if not file_path:
return False
# 统一处理为 Path 对象
if isinstance(file_path, str):
# 处理新的存储路径格式 /storage/admin/images/...
if file_path.startswith("/storage/admin/images/"):
# 提取相对路径部分
relative_path = file_path[len("/storage/admin/images/"):]
full_path = UPLOAD_DIR / relative_path
else:
# 移除开头的 / 并构建完整路径(兼容旧格式)
relative_path = file_path.lstrip("/")
full_path = UPLOAD_DIR / relative_path
else:
full_path = file_path
# 删除原文件
deleted = False
try:
if full_path.exists():
full_path.unlink()
deleted = True
print(f"[cleanup] 已删除文件: {full_path}")
except Exception as e:
print(f"[cleanup] 删除文件失败 {full_path}: {e}")
# 同步删除缩略图
# 缩略图与原图同名,但扩展名统一为 .jpg,存放在 /uploads/thumbnails/
try:
thumbnail_name = f"{full_path.stem}.jpg"
thumb_path = THUMBNAIL_DIR / thumbnail_name
if thumb_path.exists():
thumb_path.unlink()
print(f"[cleanup] 已删除缩略图: {thumb_path}")
except Exception as e:
print(f"[cleanup] 删除缩略图失败: {e}")
return deleted
def cleanup_file_by_path(relative_path: str) -> bool:
"""
根据相对路径删除文件及其缩略图
Args:
relative_path: 相对于 /uploads 的路径,如 "20250420_xxx.jpg"
或者完整的存储路径如 "/storage/admin/images/xxx.jpg"
Returns:
bool: 是否成功删除原文件
"""
if not relative_path:
return False
# 排除外部 URL
if relative_path.startswith(("http://", "https://")):
print(f"[cleanup] 跳过外部 URL: {relative_path}")
return False
# 处理新的存储路径格式 /storage/admin/images/...
if relative_path.startswith("/storage/admin/images/"):
# 提取相对路径部分
rel_path = relative_path[len("/storage/admin/images/"):]
full_path = UPLOAD_DIR / rel_path
else:
# 处理可能包含开头 / 的路径(兼容旧格式)
rel_path = relative_path.lstrip("/")
full_path = UPLOAD_DIR / rel_path
return cleanup_file(full_path)
@@ -0,0 +1,543 @@
#!/usr/bin/env python3
"""
批量采集 niaodaifu.cn 健康资讯文章
"""
import os
import re
import hashlib
import requests
import sqlite3
from datetime import datetime
from urllib.parse import urljoin, urlparse
from playwright.sync_api import sync_playwright
from bs4 import BeautifulSoup, NavigableString
from PIL import Image
import io
# ============ 配置 ============
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
UPLOAD_DIR = os.path.join(BASE_DIR, "..", "storage", "admin", "images")
DB_PATH = os.path.join(BASE_DIR, "cms.db")
MENU_TO_CATEGORY = {
15: (2, "科普文章"),
7: (3, "肾友"),
8: (4, "糖友"),
6: (5, "健康"),
}
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9",
}
# ============ 工具函数 ============
def slugify(title):
"""生成 URL 友好的 slug"""
import unicodedata
slug = unicodedata.normalize('NFKC', title)
slug = re.sub(r'[^\w\s-]', '', slug).strip().lower()
slug = re.sub(r'[-\s]+', '-', slug)
if not slug:
slug = 'article'
return slug
def ensure_unique_slug(conn, slug):
"""确保 slug 唯一"""
cursor = conn.cursor()
original = slug
suffix = 1
while True:
cursor.execute('SELECT 1 FROM news_posts WHERE slug = ?', (slug,))
if not cursor.fetchone():
break
slug = f"{original}-{suffix}"
suffix += 1
return slug
def download_image(url, module='news'):
"""下载图片到本地存储目录,返回文件路径和数据库记录信息"""
if not url or url.startswith('data:'):
return None, None
# 过滤 135editor 占位图
if '135editor.com' in url and url.endswith('.gif'):
return None, None
try:
resp = requests.get(url, headers=HEADERS, timeout=30)
if resp.status_code != 200:
return None, None
content = resp.content
if len(content) < 100:
return None, None
# 判断扩展名
ext = os.path.splitext(urlparse(url).path)[1].lower()
if ext not in ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp'):
# 尝试通过内容判断
if content[:2] == b'\xff\xd8':
ext = '.jpg'
elif content[:8] == b'\x89PNG\r\n\x1a\n':
ext = '.png'
elif content[:4] == b'GIF8':
ext = '.gif'
elif content[:4] == b'<svg':
ext = '.svg'
else:
ext = '.jpg'
# 生成文件名
filename = hashlib.md5(url.encode()).hexdigest() + ext
subdir = datetime.now().strftime('%Y%m')
dest_dir = os.path.join(UPLOAD_DIR, subdir)
os.makedirs(dest_dir, exist_ok=True)
filepath = os.path.join(dest_dir, filename)
with open(filepath, 'wb') as f:
f.write(content)
# 生成缩略图
thumb_path = None
if ext in ('.jpg', '.jpeg', '.png', '.webp', '.bmp'):
try:
img = Image.open(io.BytesIO(content))
img.thumbnail((300, 300))
thumb_filename = f"thumb_{filename}"
thumb_filepath = os.path.join(dest_dir, thumb_filename)
if ext == '.png':
img.save(thumb_filepath, 'PNG')
else:
img.save(thumb_filepath, 'JPEG', quality=85)
thumb_path = f'/storage/admin/images/{subdir}/{thumb_filename}'
except Exception:
pass
rel_path = f'/storage/admin/images/{subdir}/{filename}'
file_size = len(content)
# 获取图片尺寸
width, height = None, None
try:
img = Image.open(io.BytesIO(content))
width, height = img.size
except Exception:
pass
return rel_path, {
'filename': filename,
'original_name': os.path.basename(urlparse(url).path) or filename,
'file_path': rel_path,
'thumb_path': thumb_path,
'file_size': file_size,
'width': width,
'height': height,
'module': module,
}
except Exception as e:
print(f" [图片下载失败] {url}: {e}")
return None, None
def insert_image_to_db(conn, img_info):
"""插入图片记录到 images 表,返回 image_id"""
if not img_info:
return None
cursor = conn.cursor()
cursor.execute('''
INSERT INTO images (filename, original_name, file_path, thumb_path, description, file_size, width, height, module, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
img_info['filename'],
img_info['original_name'],
img_info['file_path'],
img_info.get('thumb_path'),
None,
img_info['file_size'],
img_info.get('width'),
img_info.get('height'),
img_info['module'],
datetime.now().isoformat(),
))
return cursor.lastrowid
def html_to_markdown(element, base_url, downloaded_images):
"""递归将 HTML 转换为 Markdown,同时下载图片"""
if element is None:
return ''
parts = []
for child in element.children:
if isinstance(child, NavigableString):
text = str(child)
# 保留换行,但压缩多余空格
parts.append(text)
continue
tag = child.name
if tag is None:
continue
# 跳过 135editor 装饰性元素和脚本
if tag in ('script', 'style', 'iframe', 'video', 'audio'):
continue
# 跳过纯装饰性 section/div(没有文本内容且没有图片)
inner_text = child.get_text(strip=True)
has_img = child.find('img') is not None
if tag in ('section', 'div') and not inner_text and not has_img:
continue
if tag == 'p':
text = html_to_markdown(child, base_url, downloaded_images).strip()
if text:
parts.append(text + '\n\n')
elif tag in ('h1', 'h2', 'h3', 'h4', 'h5', 'h6'):
level = int(tag[1])
text = html_to_markdown(child, base_url, downloaded_images).strip()
if text:
parts.append('#' * level + ' ' + text + '\n\n')
elif tag == 'img':
src = child.get('src') or child.get('_src') or child.get('data-src', '')
if src and not src.endswith('.gif'):
full_src = urljoin(base_url, src)
# 下载图片
if full_src not in downloaded_images:
rel_path, img_info = download_image(full_src)
if rel_path:
downloaded_images[full_src] = rel_path
# 同时记录到数据库
conn = sqlite3.connect(DB_PATH)
insert_image_to_db(conn, img_info)
conn.commit()
conn.close()
if full_src in downloaded_images:
alt = child.get('alt', '图片')
parts.append(f'\n![{alt}]({downloaded_images[full_src]})\n')
elif tag in ('strong', 'b'):
text = html_to_markdown(child, base_url, downloaded_images).strip()
if text:
parts.append(f'**{text}**')
elif tag in ('em', 'i'):
text = html_to_markdown(child, base_url, downloaded_images).strip()
if text:
parts.append(f'*{text}*')
elif tag == 'br':
parts.append('\n')
elif tag == 'a':
href = child.get('href', '')
text = html_to_markdown(child, base_url, downloaded_images).strip()
if text and href:
full_href = urljoin(base_url, href)
parts.append(f'[{text}]({full_href})')
elif text:
parts.append(text)
elif tag in ('ul', 'ol'):
is_ordered = (tag == 'ol')
items = []
for li in child.find_all('li', recursive=False):
li_text = html_to_markdown(li, base_url, downloaded_images).strip()
if li_text:
prefix = f"{len(items)+1}. " if is_ordered else "- "
items.append(prefix + li_text)
if items:
parts.append('\n' + '\n'.join(items) + '\n\n')
elif tag == 'blockquote':
text = html_to_markdown(child, base_url, downloaded_images).strip()
if text:
lines = text.split('\n')
quoted = '\n'.join('> ' + line for line in lines)
parts.append(quoted + '\n\n')
elif tag in ('table', 'thead', 'tbody', 'tr', 'td', 'th'):
# 简单处理表格:提取文本
text = html_to_markdown(child, base_url, downloaded_images).strip()
if text:
parts.append(text + ' ')
elif tag == 'pre':
code = child.get_text()
parts.append(f'\n```\n{code}\n```\n\n')
elif tag == 'code':
code = child.get_text()
parts.append(f'`{code}`')
else:
# 其他标签递归处理
text = html_to_markdown(child, base_url, downloaded_images)
if text:
parts.append(text)
return ''.join(parts)
def clean_markdown(md):
"""清理 Markdown 文本"""
# 合并多个空行
md = re.sub(r'\n{3,}', '\n\n', md)
# 去除行首行尾空白
md = md.strip()
return md
def parse_articles_from_page(page):
"""从当前页面解析文章列表"""
articles = []
html = page.content()
soup = BeautifulSoup(html, 'html.parser')
medias = soup.find_all('div', class_='media')
for media in medias:
heading_a = media.find('a', class_='media-heading')
if not heading_a:
continue
title = heading_a.get_text(strip=True)
href = heading_a.get('href', '')
if not href:
continue
m = re.search(r'article_id=(\d+)', href)
if not m:
continue
article_id = int(m.group(1))
cover_url = None
left_a = media.find('a', class_='media-left')
if left_a:
img = left_a.find('img')
if img:
cover_url = img.get('src')
time_str = ''
time_el = media.find('span', class_='time')
if time_el:
time_str = time_el.get_text(strip=True)
articles.append({
'article_id': article_id,
'title': title,
'href': urljoin('https://niaodaifu.cn', href),
'cover_url': cover_url,
'time': time_str,
})
return articles, soup
# ============ 采集函数 ============
def get_article_list(page, menu_id):
"""获取指定栏目的所有文章列表(通过点击分页按钮翻页)"""
base_url = f"https://niaodaifu.cn/health-information?menu_id={menu_id}"
all_articles = []
seen_ids = set()
page.goto(base_url, wait_until='networkidle', timeout=30000)
page.wait_for_timeout(3000)
# 获取总条数
html = page.content()
soup = BeautifulSoup(html, 'html.parser')
total = 0
total_el = soup.find('span', class_='el-pagination__total')
if total_el:
m = re.search(r'(\d+)', total_el.get_text())
if m:
total = int(m.group(1))
# 获取页码按钮总数(最后一个数字页码)
pager_btns = page.query_selector_all('.el-pager li.number')
total_pages = 1
if pager_btns:
# 最后一个页码按钮的文本就是总页数(可能被省略,需要点击更多后才知道)
# 先查 el-pagination__total
if total > 0:
page_size_text = ''
size_select = soup.find('input', {'class': 'el-input__inner'})
page_size = 10 # 默认每页10条
total_pages = max(1, (total + page_size - 1) // page_size)
print(f" 栏目 {menu_id} 共 {total} 篇文章,预计 {total_pages} 页")
current_page = 1
while True:
articles, soup = parse_articles_from_page(page)
new_count = 0
for art in articles:
if art['article_id'] not in seen_ids:
seen_ids.add(art['article_id'])
all_articles.append(art)
new_count += 1
print(f" 第 {current_page} 页采集到 {len(articles)} 篇(新增 {new_count} 篇)")
# 查找下一页按钮
next_btn = page.query_selector('button.btn-next')
if not next_btn:
break
# 检查是否禁用
is_disabled = next_btn.get_attribute('disabled')
if is_disabled is not None:
break
next_btn.click()
page.wait_for_timeout(2000)
current_page += 1
return all_articles
def get_article_detail(page, url):
"""获取文章详情"""
page.goto(url, wait_until='networkidle', timeout=30000)
page.wait_for_timeout(3000)
html = page.content()
soup = BeautifulSoup(html, 'html.parser')
# 标题:在 article > h3 中,格式 "首页 》栏目 》标题"
title = ''
article_el = soup.find('article')
if article_el:
h3 = article_el.find('h3')
if h3:
text = h3.get_text(strip=True)
parts = [p.strip() for p in text.split('》')]
# 过滤 "首页" 和栏目名,取最后的标题
if len(parts) >= 2:
title = parts[-1]
else:
title = text
# 来源和时间
source = ''
time_str = ''
if article_el:
info_p = article_el.find('p', class_='more-info')
if info_p:
src_el = info_p.find('span', class_='source')
if src_el:
source = src_el.get_text(strip=True).replace('来源:', '')
time_el = info_p.find('span', class_='time')
if time_el:
time_str = time_el.get_text(strip=True)
# 正文 HTML
content_div = soup.find('div', class_='article-content')
content_html = ''
if content_div:
content_html = str(content_div)
return {
'title': title,
'source': source,
'time': time_str,
'content_html': content_html,
}
def save_article(article, detail, category_id, cover_path, content_md):
"""保存文章到数据库"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
title = detail['title'] or article['title']
slug = ensure_unique_slug(conn, slugify(title))
# 解析发布时间
created_at = datetime.now().isoformat()
if detail.get('time') or article.get('time'):
t = detail.get('time') or article.get('time')
try:
created_at = datetime.strptime(t, '%Y-%m-%d %H:%M').isoformat()
except ValueError:
try:
created_at = datetime.strptime(t, '%Y-%m-%d').isoformat()
except ValueError:
pass
summary = title[:200]
if detail.get('source'):
summary = f"来源:{detail['source']} | {summary}"
cursor.execute('''
INSERT INTO news_posts (title, slug, content, content_type, summary, cover_image, category_id, is_published, is_draft, view_count, created_at, updated_at)
VALUES (?, ?, ?, 'markdown', ?, ?, ?, 1, 0, 0, ?, ?)
''', (
title,
slug,
content_md,
summary,
cover_path,
category_id,
created_at,
created_at,
))
post_id = cursor.lastrowid
conn.commit()
conn.close()
return post_id
# ============ 主程序 ============
def main():
print("=" * 60)
print("开始批量采集 niaodaifu.cn 文章")
print("=" * 60)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width': 1920, 'height': 1080})
for menu_id, (category_id, cat_name) in MENU_TO_CATEGORY.items():
print(f"\n>>> 采集栏目 [{cat_name}] menu_id={menu_id} -> category_id={category_id}")
articles = get_article_list(page, menu_id)
print(f" 共找到 {len(articles)} 篇文章")
for idx, article in enumerate(articles, 1):
print(f" [{idx}/{len(articles)}] {article['title']}")
try:
detail = get_article_detail(page, article['href'])
except Exception as e:
print(f" [详情获取失败] {e}")
continue
# 下载封面图
cover_path = None
if article.get('cover_url'):
cover_path, cover_info = download_image(article['cover_url'])
if cover_info:
conn = sqlite3.connect(DB_PATH)
insert_image_to_db(conn, cover_info)
conn.commit()
conn.close()
# 转换正文为 Markdown
content_md = ''
if detail.get('content_html'):
soup = BeautifulSoup(detail['content_html'], 'html.parser')
content_div = soup.find('div', class_='article-content')
if content_div:
downloaded_images = {}
content_md = html_to_markdown(content_div, article['href'], downloaded_images)
content_md = clean_markdown(content_md)
if not content_md.strip():
print(f" [警告] 正文为空,跳过")
continue
# 保存
try:
post_id = save_article(article, detail, category_id, cover_path, content_md)
print(f" [已入库] post_id={post_id}")
except Exception as e:
print(f" [入库失败] {e}")
browser.close()
print("\n" + "=" * 60)
print("采集完成")
print("=" * 60)
if __name__ == '__main__':
main()
@@ -0,0 +1,39 @@
from sqlalchemy import create_engine, text
for db_name in ['app.db', 'cms.db', 'website.db']:
print(f"\n{'='*60}")
print(f"Database: {db_name}")
print('='*60)
try:
engine = create_engine(f'sqlite:///{db_name}')
with engine.connect() as conn:
tables = [r[0] for r in conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"))]
print(f"Tables: {tables}")
if 'news_categories' in tables:
print("\n--- news_categories ---")
for row in conn.execute(text("SELECT id, name, slug, sort_order, is_enabled FROM news_categories ORDER BY id")):
print(row)
if 'news_posts' in tables:
print("\n--- news_posts count ---")
for row in conn.execute(text("SELECT COUNT(*) FROM news_posts")):
print(f"Total: {row[0]}")
print("\n--- news_posts sample ---")
for row in conn.execute(text("SELECT id, title, slug, category_id, is_published, view_count FROM news_posts LIMIT 5")):
print(row)
if 'columns' in tables:
print("\n--- columns ---")
for row in conn.execute(text("SELECT id, name, slug, parent_id, sort_order, is_enabled FROM columns ORDER BY id")):
print(row)
if 'articles' in tables:
print("\n--- articles count ---")
for row in conn.execute(text("SELECT COUNT(*) FROM articles")):
print(f"Total: {row[0]}")
print("\n--- articles sample ---")
for row in conn.execute(text("SELECT id, title, slug, column_id, status, view_count FROM articles LIMIT 5")):
print(row)
except Exception as e:
print(f"Error: {e}")
@@ -0,0 +1,35 @@
from sqlalchemy import create_engine, text
engine = create_engine('sqlite:///cms.db')
with engine.connect() as conn:
print("=== home_experts 表数据 ===")
rows = conn.execute(text("""
SELECT he.id, he.post_id, he.sort_order, he.is_enabled,
np.title AS old_post_title
FROM home_experts he
LEFT JOIN news_posts np ON np.id = he.post_id
ORDER BY he.sort_order
""")).fetchall()
print(f"共 {len(rows)} 条记录")
for r in rows:
print(f" id={r[0]} post_id={r[1]} sort={r[2]} enabled={r[3]} title={r[4]}")
print("\n=== 用 title 匹配 articles 表 ===")
for r in rows:
if not r[4]:
print(f" home_expert id={r[0]}: 老 post 已不存在")
continue
art = conn.execute(text("SELECT id, title FROM articles WHERE title = :t"), {"t": r[4]}).fetchone()
if art:
print(f" home_expert id={r[0]}: 老post_id={r[1]} -> 新article_id={art[0]} ({art[1]})")
else:
print(f" home_expert id={r[0]}: 老post_id={r[1]} 找不到对应的新 article (title={r[4]})")
print("\n=== home_modules 表结构 ===")
cols = conn.execute(text("PRAGMA table_info(home_modules)")).fetchall()
for c in cols:
print(f" {c[1]} {c[2]}")
print("\n=== home_modules 所有记录 ===")
rows = conn.execute(text("SELECT * FROM home_modules")).fetchall()
for r in rows:
print(f" {r}"[:300])
@@ -0,0 +1,11 @@
import sqlite3
conn = sqlite3.connect('cms.db')
c = conn.cursor()
c.execute("PRAGMA table_info(nav_menus)")
for row in c.fetchall():
print(row)
print('\n--- data ---')
c.execute("SELECT * FROM nav_menus")
for row in c.fetchall():
print(row)
conn.close()
@@ -0,0 +1,7 @@
import sqlite3
conn = sqlite3.connect('cms.db')
c = conn.cursor()
c.execute("SELECT id, code, type, content FROM home_modules WHERE type='news'")
row = c.fetchone()
print('news module:', row)
conn.close()
@@ -0,0 +1,40 @@
from sqlalchemy import create_engine, text
engine = create_engine('sqlite:///cms.db')
with engine.connect() as conn:
# 老表数量
print("=== 老数据量 ===")
for row in conn.execute(text("SELECT COUNT(*) FROM news_categories")):
print(f"news_categories: {row[0]} 条")
for row in conn.execute(text("SELECT COUNT(*) FROM news_posts")):
print(f"news_posts: {row[0]} 条")
# 新表数量
print("\n=== 新数据量 ===")
for row in conn.execute(text("SELECT COUNT(*) FROM columns")):
print(f"columns: {row[0]} 条")
for row in conn.execute(text("SELECT COUNT(*) FROM articles")):
print(f"articles: {row[0]} 条")
# 老表中 cover_image 有值的数量(即有图片的文章)
print("\n=== 老 news_posts 图片情况 ===")
for row in conn.execute(text("SELECT COUNT(*) FROM news_posts WHERE cover_image IS NOT NULL AND cover_image != ''")):
print(f"有封面图的文章: {row[0]} 条")
# 新表中 cover_image 有值的数量
print("\n=== 新 articles 图片情况 ===")
for row in conn.execute(text("SELECT COUNT(*) FROM articles WHERE cover_image IS NOT NULL AND cover_image != ''")):
print(f"有封面图的文章: {row[0]} 条")
# 抽样对比图片路径是否一致
print("\n=== 图片路径抽样对比(前5条)===")
old_imgs = [r[0] for r in conn.execute(text(
"SELECT cover_image FROM news_posts WHERE cover_image IS NOT NULL AND cover_image != '' LIMIT 5"
))]
new_imgs = [r[0] for r in conn.execute(text(
"SELECT cover_image FROM articles WHERE cover_image IS NOT NULL AND cover_image != '' LIMIT 5"
))]
print("老 cover_image:")
for p in old_imgs: print(f" {p}")
print("新 cover_image:")
for p in new_imgs: print(f" {p}")
File diff suppressed because one or more lines are too long
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<script type="module" crossorigin src="/js/polyfills-B1iEewpt.js"></script>
<title>手机就能做尿检</title>
<meta charset="UTF-8" />
<link rel="shortcut icon" href="/ico/favicon-_Z0jsgTN.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="keywords" content="尿检,体检,糖尿病,肾病,尿液,肿瘤,结石,尿常规,试纸,飞点健康,吾大夫,吴试纸" />
<meta name="description" content="“尿大夫、吾大夫”是国内移动医疗行业处于领先地位的健康服务品牌。在家做体检,疾病早发现,是糖尿病肾病慢性病健康管理平台!可以筛查糖尿病、肾病、妇科病等二十多种疾病!" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="format-detection" content="telephone=no" />
<meta name="format-detection" content="email=no" />
<script type="module" crossorigin src="/js/index-C5YcNH8a.js"></script>
<link rel="modulepreload" crossorigin href="/js/@vue-JFNH94p8.js">
<link rel="modulepreload" crossorigin href="/js/@element-plus-BjkBb207.js">
<link rel="modulepreload" crossorigin href="/js/lodash-es-CuaXstk6.js">
<link rel="modulepreload" crossorigin href="/js/@vueuse-Cv7zzI7E.js">
<link rel="modulepreload" crossorigin href="/js/dayjs-TUi1EdFu.js">
<link rel="modulepreload" crossorigin href="/js/element-plus-XGZyc0-m.js">
<link rel="modulepreload" crossorigin href="/js/vue-router-BLXIbAH3.js">
<link rel="modulepreload" crossorigin href="/js/@vant-Cjgwz4fi.js">
<link rel="modulepreload" crossorigin href="/js/vant-7nl52rYU.js">
<link rel="stylesheet" crossorigin href="/css/element-plus-BKM98fYY.css">
<link rel="stylesheet" crossorigin href="/css/vant-CO-T41ew.css">
<link rel="stylesheet" crossorigin href="/css/index-CPRJOQxN.css">
<script type="module">import.meta.url;import("_").catch(()=>1);(async function*(){})().next();if(location.protocol!="file:"){window.__vite_is_modern_browser=true}</script>
<script type="module">!function(){if(window.__vite_is_modern_browser)return;console.warn("vite: loading legacy chunks, syntax error above and the same error below should be ignored");var e=document.getElementById("vite-legacy-polyfill"),n=document.createElement("script");n.src=e.src,n.onload=function(){System.import(document.getElementById('vite-legacy-entry').getAttribute('data-src'))},document.body.appendChild(n)}();</script>
</head>
<body>
<div id="app"></div>
<script nomodule>!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",(function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()}),!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();</script>
<script nomodule crossorigin id="vite-legacy-polyfill" src="/js/polyfills-legacy-O1vO_gOd.js"></script>
<script nomodule crossorigin id="vite-legacy-entry" data-src="/js/index-legacy-Bf-YSZBI.js">System.import(document.getElementById('vite-legacy-entry').getAttribute('data-src'))</script>
</body>
</html>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,328 @@
#!/usr/bin/env python3
"""
采集静态页面到本地目录
用法: python3 crawl_static_page.py <url> <output_dir>
示例: python3 crawl_static_page.py https://mubaitea.cn/zsjm /var/www/new-media-platform/website-new/static/
"""
import os
import re
import sys
import hashlib
import requests
from datetime import datetime
from urllib.parse import urljoin, urlparse
from playwright.sync_api import sync_playwright
from bs4 import BeautifulSoup
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "*/*",
}
def slugify(url):
"""从 URL 生成文件名"""
path = urlparse(url).path.strip('/')
if path:
name = path.split('/')[-1]
if '.' in name:
name = name.rsplit('.', 1)[0]
return name or 'index'
return 'index'
def download_file(url, dest_dir, skip_existing=True):
"""下载文件到指定目录,返回本地相对路径"""
if not url or url.startswith('data:'):
return None
# 补全协议
if url.startswith('//'):
url = 'https:' + url
try:
# 生成文件名
parsed = urlparse(url)
path_parts = parsed.path.strip('/').split('/')
filename = path_parts[-1] if path_parts else 'file'
# 如果文件名没有扩展名或太泛,用hash重命名
ext = os.path.splitext(filename)[1].lower()
if not ext or len(filename) < 5:
url_hash = hashlib.md5(url.encode()).hexdigest()[:10]
filename = f"{url_hash}{ext or '.bin'}"
dest_path = os.path.join(dest_dir, filename)
if skip_existing and os.path.exists(dest_path):
return filename
resp = requests.get(url, headers=HEADERS, timeout=30)
if resp.status_code != 200:
print(f" [下载失败] {url} HTTP {resp.status_code}")
return None
os.makedirs(dest_dir, exist_ok=True)
with open(dest_path, 'wb') as f:
f.write(resp.content)
print(f" [已下载] {url} -> {filename} ({len(resp.content)} bytes)")
return filename
except Exception as e:
print(f" [下载失败] {url}: {e}")
return None
def extract_resource_urls(html_snippet, base_url):
"""从 HTML 片段中提取所有资源 URL"""
soup = BeautifulSoup(html_snippet, 'html.parser')
images = set()
scripts = set()
stylesheets = set()
# 图片
for tag in soup.find_all(['img', 'image']):
for attr in ['src', '_src', 'data-src', 'data-original']:
src = tag.get(attr)
if src and not src.startswith('data:'):
full = urljoin(base_url, src)
if any(ext in full.lower() for ext in ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico']):
images.add(full)
# CSS background-image (inline style)
for tag in soup.find_all(style=True):
style = tag['style']
urls = re.findall(r'url\([\"\']?([^\"\'()]+)[\"\']?\)', style)
for u in urls:
if not u.startswith('data:'):
full = urljoin(base_url, u)
images.add(full)
return {'images': list(images), 'scripts': list(scripts), 'stylesheets': list(stylesheets)}
def crawl_static_page(url, output_dir):
"""主函数:采集页面到本地目录"""
js_dir = os.path.join(output_dir, 'js')
images_dir = os.path.join(output_dir, 'images')
css_dir = os.path.join(output_dir, 'css')
page_name = slugify(url)
html_path = os.path.join(output_dir, f'{page_name}.html')
print(f"目标 URL: {url}")
print(f"输出目录: {output_dir}")
print(f"HTML 文件: {html_path}")
print()
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width': 1920, 'height': 1080})
page.goto(url, wait_until='networkidle', timeout=30000)
page.wait_for_timeout(5000)
html = page.content()
soup = BeautifulSoup(html, 'html.parser')
# === 1. 提取正文内容(ctrl-location="main") ===
main_elements = soup.find_all(attrs={'ctrl-location': 'main'})
print(f"找到 {len(main_elements)} 个正文元素 (ctrl-location=main)")
# 取顶层 main 元素(父级不是 main 的)
top_level_mains = []
for el in main_elements:
parent = el.parent
if parent and parent.get('ctrl-location') != 'main':
top_level_mains.append(el)
print(f"其中顶层元素: {len(top_level_mains)} 个")
# 收集 main 内容的 HTML
main_html_parts = []
for el in top_level_mains:
main_html_parts.append(str(el))
main_content_html = '\n'.join(main_html_parts)
# === 2. 提取页面 CSS 引用 ===
css_links = []
for link in soup.find_all('link', rel='stylesheet'):
href = link.get('href', '')
if href:
css_links.append(href)
# 页面内联 style
inline_styles = []
for style_tag in soup.find_all('style'):
inline_styles.append(str(style_tag))
# === 3. 提取页面 JS 引用 ===
script_srcs = []
for script in soup.find_all('script', src=True):
src = script['src']
if src:
script_srcs.append(src)
# === 4. 提取 SVG symbol 图标(通常在 body 开头) ===
svg_sprite = ''
body = soup.find('body', attrs={'area': 'main'})
if body:
svg = body.find('svg', recursive=False)
if svg:
svg_sprite = str(svg)
print("找到 SVG 图标精灵")
# === 5. 提取 head 中的 meta/viewport ===
head_meta = ''
head = soup.find('head')
if head:
meta_tags = head.find_all('meta')
for meta in meta_tags:
head_meta += str(meta) + '\n'
# 也保留 title
title_tag = head.find('title')
if title_tag:
head_meta += str(title_tag) + '\n'
browser.close()
# === 6. 下载图片 ===
print("\n=== 下载图片 ===")
resources = extract_resource_urls(main_content_html, url)
image_map = {} # original_url -> local_filename
for img_url in resources['images']:
local_name = download_file(img_url, images_dir)
if local_name:
image_map[img_url] = f'images/{local_name}'
# 也下载 main 内容中 img 标签的图片(更可靠的方式)
main_soup = BeautifulSoup(main_content_html, 'html.parser')
for img in main_soup.find_all('img'):
for attr in ['src', '_src', 'data-src', 'data-original']:
src = img.get(attr)
if src and not src.startswith('data:'):
full = urljoin(url, src)
if full not in image_map:
local_name = download_file(full, images_dir)
if local_name:
image_map[full] = f'images/{local_name}'
# === 7. 下载 JS 文件 ===
print("\n=== 下载 JS 文件 ===")
js_map = {}
for js_url in script_srcs:
local_name = download_file(js_url, js_dir)
if local_name:
js_map[js_url] = f'js/{local_name}'
# === 8. 下载 CSS 文件 ===
print("\n=== 下载 CSS 文件 ===")
css_map = {}
css_contents = []
for css_url in css_links:
local_name = download_file(css_url, css_dir)
if local_name:
css_map[css_url] = f'css/{local_name}'
# 读取 CSS 内容以便内联 background-image 中的 URL
css_path = os.path.join(css_dir, local_name)
try:
with open(css_path, 'r', encoding='utf-8', errors='replace') as f:
css_contents.append(f.read())
except:
pass
# === 9. 替换 HTML 中的资源路径 ===
print("\n=== 替换资源路径 ===")
# 替换图片
for orig_url, local_path in image_map.items():
# 处理各种 URL 格式
main_content_html = main_content_html.replace(orig_url, local_path)
# 处理 protocol-relative
if orig_url.startswith('https://'):
proto_rel = orig_url.replace('https:', '')
main_content_html = main_content_html.replace(proto_rel, local_path)
# 替换 JS
for orig_url, local_path in js_map.items():
main_content_html = main_content_html.replace(orig_url, local_path)
if orig_url.startswith('https://'):
proto_rel = orig_url.replace('https:', '')
main_content_html = main_content_html.replace(proto_rel, local_path)
# 替换 CSS
for orig_url, local_path in css_map.items():
main_content_html = main_content_html.replace(orig_url, local_path)
if orig_url.startswith('https://'):
proto_rel = orig_url.replace('https:', '')
main_content_html = main_content_html.replace(proto_rel, local_path)
# 构建 CSS link 标签(使用本地路径)
local_css_links = ''
for orig_url, local_path in css_map.items():
local_css_links += f' <link rel="stylesheet" href="{local_path}">\n'
# 构建 JS script 标签(使用本地路径)
local_js_scripts = ''
for orig_url, local_path in js_map.items():
local_js_scripts += f' <script src="{local_path}"></script>\n'
# === 10. 构建完整 HTML ===
full_html = f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1200, initial-scale=1.0">
{head_meta}
{local_css_links}
{chr(10).join(f' <style>{s}</style>' for s in inline_styles if s.strip())}
<style>
/* 确保主内容区域正确显示 */
body {{
margin: 0;
padding: 0;
background: #fff;
font-family: "Microsoft YaHei", "微软雅黑", Arial, sans-serif;
}}
.main-content-wrapper {{
position: relative;
width: 1200px;
margin: 0 auto;
min-height: 100vh;
}}
</style>
</head>
<body>
{svg_sprite}
<div class="main-content-wrapper">
{main_content_html}
</div>
{local_js_scripts}
</body>
</html>'''
# 写入文件
with open(html_path, 'w', encoding='utf-8') as f:
f.write(full_html)
print(f"\n=== 完成 ===")
print(f"HTML: {html_path}")
print(f"图片: {len(image_map)} 个 -> {images_dir}")
print(f"JS: {len(js_map)} 个 -> {js_dir}")
print(f"CSS: {len(css_map)} 个 -> {css_dir}")
print(f"文件大小: {len(full_html)} 字节")
if __name__ == '__main__':
if len(sys.argv) < 3:
print("用法: python3 crawl_static_page.py <url> <output_dir>")
print("示例: python3 crawl_static_page.py https://mubaitea.cn/zsjm /var/www/new-media-platform/website-new/static/")
sys.exit(1)
target_url = sys.argv[1]
output_dir = sys.argv[2].rstrip('/')
crawl_static_page(target_url, output_dir)
@@ -0,0 +1,7 @@
import sqlite3
conn = sqlite3.connect('cms.db')
c = conn.cursor()
c.execute("SELECT id, title, cover_image FROM news_posts WHERE cover_image IS NOT NULL AND cover_image != '' LIMIT 20")
for row in c.fetchall():
print(f'id={row[0]}: {row[2]}')
conn.close()
@@ -0,0 +1,13 @@
import sqlite3, json
conn = sqlite3.connect('cms.db')
c = conn.cursor()
c.execute("SELECT content FROM home_modules WHERE type='features'")
row = c.fetchone()
if row:
content = json.loads(row[0])
for i, item in enumerate(content.get('items', [])):
print(f'[{i}] id={item.get("id")} title={item.get("title")}')
print(f' media_url={item.get("media_url")}')
print(f' image={item.get("image")}')
print(f' media_type={item.get("media_type")}')
conn.close()
@@ -0,0 +1,7 @@
import sqlite3
conn = sqlite3.connect('cms.db')
c = conn.cursor()
c.execute("SELECT id, title, cover_image FROM news_posts WHERE cover_image LIKE '%36624528%'")
for row in c.fetchall():
print('post:', row)
conn.close()
@@ -0,0 +1,18 @@
import sqlite3, json
conn = sqlite3.connect('cms.db')
c = conn.cursor()
# 查看 news 模块 content
c.execute("SELECT id, code, content FROM home_modules WHERE type='news'")
row = c.fetchone()
print('news module:', row)
if row:
content = json.loads(row[2])
print('posts:', json.dumps(content.get('posts'), indent=2, ensure_ascii=False))
# 查看一些已发布文章的 cover_image 路径
c.execute("SELECT id, title, cover_image, slug FROM news_posts WHERE is_published=1 LIMIT 5")
for r in c.fetchall():
print('post:', r)
conn.close()
@@ -0,0 +1,32 @@
"""彻底删除老的 news_posts / news_categories 表。
运行前已手动备份 cms.db -> cms.db.backup_before_news_cleanup_20260429
"""
from sqlalchemy import create_engine, text
engine = create_engine('sqlite:///cms.db')
with engine.connect() as conn:
# 先看看在不在
tables_before = [r[0] for r in conn.execute(text(
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('news_posts','news_categories')"
)).fetchall()]
print(f"清理前存在的老表: {tables_before}")
# 记录数据量(仅记录,不影响清理)
for t in tables_before:
cnt = conn.execute(text(f"SELECT COUNT(*) FROM {t}")).scalar()
print(f" {t}: {cnt} 行")
# DROP 顺序:先 news_posts(含 FK 指向 news_categories),再 news_categories
if 'news_posts' in tables_before:
conn.execute(text("DROP TABLE news_posts"))
print("已 DROP TABLE news_posts")
if 'news_categories' in tables_before:
conn.execute(text("DROP TABLE news_categories"))
print("已 DROP TABLE news_categories")
conn.commit()
tables_after = [r[0] for r in conn.execute(text(
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('news_posts','news_categories')"
)).fetchall()]
print(f"\n清理后存在的老表: {tables_after}(应为空)")
@@ -0,0 +1,34 @@
import sqlite3
conn = sqlite3.connect('cms.db')
c = conn.cursor()
# 查更新前时间范围
c.execute('SELECT MIN(created_at), MAX(created_at), COUNT(*) FROM news_posts WHERE id >= 2')
row = c.fetchone()
print(f'更新前时间范围: {row[0]} ~ {row[1]},共 {row[2]} 篇')
# 一次性将所有采集文章的 created_at 和 updated_at 加2年
# 使用 SQLite 的 datetime 函数,一条语句全部搞定,不存在顺序问题
c.execute("UPDATE news_posts SET created_at = datetime(created_at, '+2 years'), updated_at = datetime(updated_at, '+2 years') WHERE id >= 2")
print(f'已更新 {c.rowcount} 条记录')
conn.commit()
# 验证更新后的范围
c.execute('SELECT MIN(created_at), MAX(created_at) FROM news_posts WHERE id >= 2')
row = c.fetchone()
print(f'更新后时间范围: {row[0]} ~ {row[1]}')
# 抽样几条查看
c.execute('SELECT id, title, created_at FROM news_posts WHERE id >= 2 ORDER BY created_at DESC LIMIT 5')
print('\n最新5篇:')
for r in c.fetchall():
print(f' id={r[0]} created_at={r[2]} title={r[1][:20]}')
c.execute('SELECT id, title, created_at FROM news_posts WHERE id >= 2 ORDER BY created_at ASC LIMIT 5')
print('\n最早5篇:')
for r in c.fetchall():
print(f' id={r[0]} created_at={r[2]} title={r[1][:20]}')
conn.close()
print('\n完成!')
@@ -0,0 +1,29 @@
import sqlite3, json
conn = sqlite3.connect('cms.db')
c = conn.cursor()
c.execute("SELECT id, content FROM home_modules WHERE type='features'")
row = c.fetchone()
if row:
content = json.loads(row[1])
items = content.get('items', [])
fixed = False
for i, item in enumerate(items):
# 同步 image 和 media_url:如果 image 有值,media_url 应该一致
if item.get('image') and item.get('image') != item.get('media_url'):
print(f'[{i}] sync media_url to image: {item["image"]}')
item['media_url'] = item['image']
fixed = True
# 第一个 item 的特殊修复:如果 media_url 指向不存在的 202604 文件且 image 为空
if i == 0 and item.get('media_url') == '/storage/admin/images/202604/1aaf94e5.jpg' and not item.get('image'):
print(f'[{i}] fix broken media_url to feature1.png')
item['media_url'] = '/storage/admin/images/features/feature1.png'
fixed = True
if fixed:
c.execute("UPDATE home_modules SET content = ? WHERE type = 'features'",
(json.dumps(content, ensure_ascii=False),))
conn.commit()
print('fixed and saved')
else:
print('no fix needed')
conn.close()
@@ -0,0 +1,53 @@
"""
修复 home_modules[news].content.posts 里的旧 post 快照:
- 按 title 匹配新的 articles 表
- 更新 id 和 slug 为新表中的值
- 同步 cover_image / summary(以新表为准)
"""
import json
from sqlalchemy import create_engine, text
engine = create_engine('sqlite:///cms.db')
with engine.connect() as conn:
# 读取 news 模块的 content
row = conn.execute(text("SELECT id, content FROM home_modules WHERE code='news' LIMIT 1")).fetchone()
if not row:
print("未找到 news 模块,跳过")
raise SystemExit(0)
mod_id, content_str = row
content = json.loads(content_str) if content_str else {}
posts = content.get('posts', [])
print(f"home_modules[news].posts 共 {len(posts)} 条快照")
updated = 0
for idx, post in enumerate(posts):
if not post:
continue
title = post.get('title')
if not title:
continue
art = conn.execute(
text("SELECT id, title, slug, summary, cover_image FROM articles WHERE title = :t LIMIT 1"),
{"t": title}
).fetchone()
if art:
old_slug = post.get('slug')
post['id'] = art[0]
post['slug'] = art[2]
post['summary'] = art[3] or post.get('summary', '')
post['cover_image'] = art[4] or post.get('cover_image', '')
updated += 1
print(f" [{idx}] {title[:40]}... | 老 slug={old_slug} -> 新 slug={art[2]}")
else:
print(f" [{idx}] {title[:40]}... | 找不到对应的新 article(已清空此快照位)")
posts[idx] = None # 置空该位
content['posts'] = posts
new_content_str = json.dumps(content, ensure_ascii=False)
conn.execute(
text("UPDATE home_modules SET content = :c WHERE id = :id"),
{"c": new_content_str, "id": mod_id}
)
conn.commit()
print(f"\n更新完成:成功映射 {updated} 条;写入 home_modules.id={mod_id}")
@@ -0,0 +1,78 @@
"""
一次性修复三个产品页的图片:
1. 从 website-old 拷贝图片到 202605 目录(加前缀避免重名)
2. 更新数据库内容中的图片路径
"""
import sqlite3
import shutil
import os
import re
BASE = "/var/www/new-media-platform/website-new/storage/admin/images"
DEST = f"{BASE}/202605"
os.makedirs(DEST, exist_ok=True)
DB = "/var/www/new-media-platform/website-new/backend/cms.db"
# 三个产品:数据库ID, 旧路径前缀, 源图片目录, 新文件名前缀
PRODUCTS = [
(345, "products/niaojianyun", "/home/ubuntu/website-old/src/assets/img/product-niaojianyun", "njjy"),
(346, "products/wudaifu", "/home/ubuntu/website-old/src/assets/img/product-wudaifu", "wdf"),
(347, "products/wushizhi", "/home/ubuntu/website-old/src/assets/img/product-wushizhi", "wsz"),
]
conn = sqlite3.connect(DB)
cur = conn.cursor()
for aid, old_prefix, src_dir, prefix in PRODUCTS:
print(f"\n=== 处理 ID={aid}, 前缀={prefix} ===")
# 获取文章内容
cur.execute("SELECT content FROM articles WHERE id=?", (aid,))
row = cur.fetchone()
if not row or not row[0]:
print(f" ✗ 文章 {aid} 无内容")
continue
content = row[0]
# 找出所有需要迁移的图片
old_pattern = rf'/storage/admin/images/{old_prefix}/([^"\']+)'
matches = list(re.finditer(old_pattern, content))
print(f" 找到 {len(matches)} 个图片引用")
new_content = content
for m in matches:
old_filename = m.group(1)
new_filename = f"{prefix}-{old_filename}"
src_path = os.path.join(src_dir, old_filename)
dst_path = os.path.join(DEST, new_filename)
old_full = m.group(0)
new_full = f"/storage/admin/images/202605/{new_filename}"
# 拷贝文件
if os.path.exists(src_path):
shutil.copy2(src_path, dst_path)
print(f" ✓ {old_filename} → {new_filename}")
else:
# 也许已经在 products/ 目录下
alt_src = os.path.join(BASE, old_prefix, old_filename)
if os.path.exists(alt_src):
shutil.copy2(alt_src, dst_path)
print(f" ✓ {old_filename} → {new_filename} (from products/)")
else:
print(f" ✗ 源文件不存在: {old_filename}")
continue
# 替换路径
new_content = new_content.replace(old_full, new_full)
# 更新数据库
cur.execute("UPDATE articles SET content=? WHERE id=?", (new_content, aid))
print(f" 数据库已更新 (ID={aid})")
conn.commit()
conn.close()
print("\n=== 完成 ===")
print(f"所有图片已拷贝到: {DEST}/")
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""
Initialize database with default data
Usage: python init_db.py
"""
import sys
import os
# Add app to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app.database import engine, SessionLocal, Base
from app.models import Admin, HomeModule, NavMenu, Setting
from app.auth import get_password_hash
import json
def init_database():
print("Creating database tables...")
Base.metadata.create_all(bind=engine)
db = SessionLocal()
try:
# Check if already initialized
if db.query(Admin).first():
print("Database already initialized!")
return
print("Creating default admin...")
admin = Admin(
username="admin",
password_hash=get_password_hash("abcd1234")
)
db.add(admin)
print("Creating default home modules...")
default_modules = [
HomeModule(
name="轮播图",
code="banner",
type="banner",
sort_order=1,
is_enabled=True,
is_fixed=True,
content=json.dumps({"slides": []}),
style_config=json.dumps({"backgroundColor": "#ffffff"})
),
HomeModule(
name="产品优势",
code="features",
type="features",
sort_order=2,
is_enabled=True,
is_fixed=False,
title="为什么选择我们",
content=json.dumps({"items": []}),
style_config=json.dumps({"backgroundColor": "#FEF9E7"})
),
HomeModule(
name="新闻动态",
code="news",
type="news",
sort_order=3,
is_enabled=True,
is_fixed=False,
title="新闻动态",
content=json.dumps({"count": 4}),
style_config=json.dumps({"backgroundColor": "#ffffff"})
),
HomeModule(
name="合作伙伴",
code="partners",
type="partners",
sort_order=4,
is_enabled=True,
is_fixed=False,
title="合作伙伴",
content=json.dumps({"logos": []}),
style_config=json.dumps({"backgroundColor": "#FEF9E7"})
),
HomeModule(
name="APP下载",
code="downloads",
type="downloads",
sort_order=5,
is_enabled=True,
is_fixed=False,
title="下载我们的APP",
content=json.dumps({"apps": []}),
style_config=json.dumps({"backgroundColor": "#ffffff"})
),
HomeModule(
name="联系我们",
code="contact",
type="contact",
sort_order=6,
is_enabled=True,
is_fixed=False,
title="联系我们",
content=json.dumps({"phone": "", "email": "", "address": ""}),
style_config=json.dumps({"backgroundColor": "#FEF9E7"})
),
HomeModule(
name="专家团队",
code="experts",
type="experts",
sort_order=7,
is_enabled=True,
is_fixed=False,
title="专家团队",
content=json.dumps({"view_more_link": ""}),
style_config=json.dumps({"backgroundColor": "#ffffff"})
)
]
for module in default_modules:
db.add(module)
print("Creating default nav menus...")
default_menus = [
NavMenu(name="首页", link="/", sort_order=1, is_enabled=True),
NavMenu(name="产品介绍", link="/product", sort_order=2, is_enabled=True),
NavMenu(name="新闻动态", link="/news", sort_order=3, is_enabled=True),
NavMenu(name="关于我们", link="/about", sort_order=4, is_enabled=True),
NavMenu(name="联系我们", link="/contact", sort_order=5, is_enabled=True),
]
for menu in default_menus:
db.add(menu)
print("Creating default settings...")
default_settings = [
Setting(key="site_name", value="飞点健康", group="general", description="网站名称"),
Setting(key="site_logo", value="/logo.png", group="general", description="网站Logo"),
Setting(key="company_name", value="深圳飞点健康科技有限公司", group="company", description="公司名称"),
Setting(key="copyright", value="© 2024 飞点健康 版权所有", group="general", description="版权信息"),
]
for setting in default_settings:
db.add(setting)
db.commit()
print("✅ Database initialized successfully!")
print("\nDefault admin credentials:")
print(" Username: admin")
print(" Password: abcd1234")
except Exception as e:
db.rollback()
print(f"❌ Error: {e}")
raise
finally:
db.close()
if __name__ == "__main__":
init_database()
@@ -0,0 +1,14 @@
import sqlite3
conn = sqlite3.connect('cms.db')
c = conn.cursor()
c.execute(
"INSERT INTO nav_menus (name, link, sort_order, is_enabled, is_external, parent_id) VALUES (?, ?, ?, ?, ?, ?)",
('公司介绍', '/about/company', 1, 1, 0, 4)
)
c.execute(
"INSERT INTO nav_menus (name, link, sort_order, is_enabled, is_external, parent_id) VALUES (?, ?, ?, ?, ?, ?)",
('团队风采', '/about/team', 2, 1, 0, 4)
)
conn.commit()
conn.close()
print('test submenus inserted')
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""迁移 home_modules 表中的图片路径"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from sqlalchemy import create_engine, text
from app.config import get_settings
settings = get_settings()
engine = create_engine(settings.DATABASE_URL.replace('+aiosqlite', ''), echo=False)
def update_banner_paths():
"""更新 banner 模块中的图片路径"""
with engine.connect() as conn:
result = conn.execute(text('SELECT id, code, content FROM home_modules WHERE code = "banner"'))
row = result.fetchone()
if not row:
print("未找到 banner 模块")
return
module_id = row[0]
code = row[1]
content = json.loads(row[2]) if row[2] else {}
slides = content.get('slides', [])
updated = False
for slide in slides:
old_image = slide.get('image', '')
if old_image.startswith('/uploads/'):
# 转换路径
new_image = old_image.replace('/uploads/', '/storage/admin/images/')
slide['image'] = new_image
print(f" 更新: {old_image} -> {new_image}")
updated = True
if updated:
new_content = json.dumps(content, ensure_ascii=False)
conn.execute(
text('UPDATE home_modules SET content = :content WHERE id = :id'),
{'content': new_content, 'id': module_id}
)
conn.commit()
print(f"\n✅ banner 模块已更新")
else:
print("✅ banner 模块无需更新")
def update_features_paths():
"""更新 features 模块中的图片路径"""
with engine.connect() as conn:
result = conn.execute(text('SELECT id, code, content FROM home_modules WHERE code = "features"'))
row = result.fetchone()
if not row:
print("未找到 features 模块")
return
module_id = row[0]
content = json.loads(row[2]) if row[2] else {}
items = content.get('items', [])
updated = False
for item in items:
# 更新 icon 字段
old_icon = item.get('icon', '')
if old_icon.startswith('/uploads/') or old_icon.startswith('/images/'):
if old_icon.startswith('/uploads/'):
new_icon = old_icon.replace('/uploads/', '/storage/admin/images/')
else:
new_icon = old_icon.replace('/images/', '/storage/admin/images/')
item['icon'] = new_icon
print(f" 更新 icon: {old_icon} -> {new_icon}")
updated = True
# 更新 media_url 字段
old_media = item.get('media_url', '')
if old_media.startswith('/uploads/') or old_media.startswith('/images/'):
if old_media.startswith('/uploads/'):
new_media = old_media.replace('/uploads/', '/storage/admin/images/')
else:
new_media = old_media.replace('/images/', '/storage/admin/images/')
item['media_url'] = new_media
print(f" 更新 media_url: {old_media} -> {new_media}")
updated = True
if updated:
new_content = json.dumps(content, ensure_ascii=False)
conn.execute(
text('UPDATE home_modules SET content = :content WHERE id = :id'),
{'content': new_content, 'id': module_id}
)
conn.commit()
print(f"\n✅ features 模块已更新")
else:
print("✅ features 模块无需更新")
if __name__ == "__main__":
print("=" * 60)
print("迁移 home_modules 图片路径")
print("=" * 60)
update_banner_paths()
update_features_paths()
print("\n" + "=" * 60)
print("迁移完成")
print("=" * 60)
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""
图片存储方案迁移脚本
将图片从旧的层级目录结构迁移到新的扁平结构
旧结构: /{module}/{year}/{month}/{filename}
/{module}/{year}/{month}/thumbnails/thumb_{filename}.jpg
新结构: /{filename}
/thumbnails/{filename_stem}.jpg
"""
import os
import sys
import shutil
from pathlib import Path
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker, Session
# 添加项目路径
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app.config import get_settings
settings = get_settings()
# 数据库路径
DB_PATH = "./cms.db"
UPLOAD_DIR = Path(settings.UPLOAD_DIR)
def migrate_images():
"""迁移图片路径"""
engine = create_engine(f"sqlite:///{DB_PATH}")
SessionLocal = sessionmaker(bind=engine)
db = SessionLocal()
try:
# 获取所有图片记录
result = db.execute(text("SELECT id, file_path, thumb_path, filename FROM images"))
images = result.fetchall()
print(f"找到 {len(images)} 条图片记录需要迁移")
migrated_count = 0
skipped_count = 0
error_count = 0
for img in images:
try:
img_id = img[0]
old_file_path = img[1] # e.g., /news/2026/04/20260420_121931_b91c5d3a.jpg
old_thumb_path = img[2] # e.g., /news/2026/04/thumbnails/thumb_20260420_121931_b91c5d3a.jpg.jpg
filename = img[3] # e.g., 20260420_121931_b91c5d3a.jpg
# 如果已经是新格式,跳过
if old_file_path.startswith("/") and "/" not in old_file_path.lstrip("/"):
print(f"跳过 (已是新格式): {old_file_path}")
skipped_count += 1
continue
# 新的路径格式
new_file_path = f"/{filename}"
new_thumb_path = f"/thumbnails/{Path(filename).stem}.jpg" if old_thumb_path else None
# 物理文件路径
old_file_full = UPLOAD_DIR / old_file_path.lstrip("/")
new_file_full = UPLOAD_DIR / filename
# 移动原图文件
if old_file_full.exists() and old_file_full != new_file_full:
try:
shutil.move(str(old_file_full), str(new_file_full))
print(f"移动文件: {old_file_full} -> {new_file_full}")
except Exception as e:
print(f"移动文件失败 {old_file_full}: {e}")
error_count += 1
continue
# 处理缩略图
if old_thumb_path:
old_thumb_full = UPLOAD_DIR / old_thumb_path.lstrip("/")
new_thumb_full = UPLOAD_DIR / "thumbnails" / f"{Path(filename).stem}.jpg"
if old_thumb_full.exists() and old_thumb_full != new_thumb_full:
try:
shutil.move(str(old_thumb_full), str(new_thumb_full))
print(f"移动缩略图: {old_thumb_full} -> {new_thumb_full}")
except Exception as e:
print(f"移动缩略图失败 {old_thumb_full}: {e}")
# 更新数据库
db.execute(
text("UPDATE images SET file_path = :new_path, thumb_path = :new_thumb WHERE id = :id"),
{"new_path": new_file_path, "new_thumb": new_thumb_path, "id": img_id}
)
migrated_count += 1
print(f"迁移成功 ID={img_id}: {old_file_path} -> {new_file_path}")
except Exception as e:
print(f"迁移失败 ID={img_id}: {e}")
error_count += 1
db.commit()
print("\n" + "="*60)
print(f"迁移完成!")
print(f"成功: {migrated_count}")
print(f"跳过 (已是新格式): {skipped_count}")
print(f"失败: {error_count}")
print("="*60)
# 清理空目录
cleanup_empty_dirs(UPLOAD_DIR)
except Exception as e:
print(f"迁移过程出错: {e}")
db.rollback()
finally:
db.close()
def cleanup_empty_dirs(root_dir: Path):
"""清理空目录"""
removed_count = 0
for dirpath, dirnames, filenames in os.walk(str(root_dir), topdown=False):
dir_path = Path(dirpath)
# 跳过根目录和 thumbnails 目录
if dir_path == root_dir or dir_path.name == "thumbnails":
continue
# 检查是否为空
try:
if dir_path.exists() and not any(dir_path.iterdir()):
dir_path.rmdir()
print(f"删除空目录: {dir_path}")
removed_count += 1
except Exception as e:
print(f"删除目录失败 {dir_path}: {e}")
print(f"清理完成,共删除 {removed_count} 个空目录")
if __name__ == "__main__":
print("图片存储方案迁移工具")
print("="*60)
print(f"数据库: {DB_PATH}")
print(f"上传目录: {UPLOAD_DIR}")
print("="*60)
confirm = input("\n确定要开始迁移吗? (yes/no): ")
if confirm.lower() == "yes":
migrate_images()
else:
print("已取消迁移")
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""
图片存储方案迁移脚本 - 自动执行版
将图片从旧的层级目录结构迁移到新的扁平结构
旧结构: /{module}/{year}/{month}/{filename}
/{module}/{year}/{month}/thumbnails/thumb_{filename}.jpg
新结构: /{filename}
/thumbnails/{filename_stem}.jpg
使用方法: python migrate_images_auto.py [--dry-run]
"""
import os
import sys
import shutil
import argparse
from pathlib import Path
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
# 添加项目路径
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app.config import get_settings
settings = get_settings()
# 数据库路径
DB_PATH = "./cms.db"
UPLOAD_DIR = Path(settings.UPLOAD_DIR)
def migrate_images(dry_run=False):
"""迁移图片路径"""
engine = create_engine(f"sqlite:///{DB_PATH}")
SessionLocal = sessionmaker(bind=engine)
db = SessionLocal()
migrated = []
skipped = []
errors = []
try:
# 获取所有图片记录
result = db.execute(text("SELECT id, file_path, thumb_path, filename FROM images"))
images = result.fetchall()
print(f"找到 {len(images)} 条图片记录")
for img in images:
try:
img_id = img[0]
old_file_path = img[1]
old_thumb_path = img[2]
filename = img[3]
# 如果已经是新格式(只有一级路径),跳过
if old_file_path and old_file_path.startswith("/"):
path_parts = old_file_path.lstrip("/").split("/")
if len(path_parts) == 1 and "/thumbnails/" not in old_file_path:
skipped.append({"id": img_id, "path": old_file_path})
continue
# 新的路径格式
new_file_path = f"/{filename}"
new_thumb_path = None
if old_thumb_path:
new_thumb_path = f"/thumbnails/{Path(filename).stem}.jpg"
if dry_run:
print(f"[DRY RUN] ID={img_id}: {old_file_path} -> {new_file_path}")
if old_thumb_path:
print(f"[DRY RUN] 缩略图: {old_thumb_path} -> {new_thumb_path}")
continue
# 物理文件路径
old_file_full = UPLOAD_DIR / old_file_path.lstrip("/") if old_file_path else None
new_file_full = UPLOAD_DIR / filename
# 移动原图文件
if old_file_full and old_file_full.exists() and old_file_full != new_file_full:
try:
shutil.move(str(old_file_full), str(new_file_full))
print(f"移动文件: {old_file_full} -> {new_file_full}")
except Exception as e:
print(f"移动文件失败 {old_file_full}: {e}")
errors.append({"id": img_id, "error": f"移动原图失败: {e}"})
continue
# 处理缩略图
if old_thumb_path:
old_thumb_full = UPLOAD_DIR / old_thumb_path.lstrip("/")
new_thumb_full = UPLOAD_DIR / "thumbnails" / f"{Path(filename).stem}.jpg"
if old_thumb_full.exists() and old_thumb_full != new_thumb_full:
try:
shutil.move(str(old_thumb_full), str(new_thumb_full))
print(f"移动缩略图: {old_thumb_full} -> {new_thumb_full}")
except Exception as e:
print(f"移动缩略图失败 {old_thumb_full}: {e}")
# 更新数据库
db.execute(
text("UPDATE images SET file_path = :new_path, thumb_path = :new_thumb WHERE id = :id"),
{"new_path": new_file_path, "new_thumb": new_thumb_path, "id": img_id}
)
migrated.append({"id": img_id, "new_path": new_file_path})
print(f"迁移成功 ID={img_id}: {old_file_path} -> {new_file_path}")
except Exception as e:
error_msg = f"迁移失败 ID={img_id}: {e}"
print(error_msg)
errors.append({"id": img_id, "error": str(e)})
if not dry_run:
db.commit()
print("\n" + "="*60)
print(f"迁移结果:")
print(f"成功: {len(migrated)}")
print(f"跳过 (已是新格式): {len(skipped)}")
print(f"失败: {len(errors)}")
print("="*60)
if errors:
print("\n错误详情:")
for err in errors:
print(f" ID={err['id']}: {err['error']}")
# 清理空目录(仅在非 dry-run 时)
if not dry_run:
cleanup_empty_dirs(UPLOAD_DIR)
except Exception as e:
print(f"迁移过程出错: {e}")
if not dry_run:
db.rollback()
finally:
db.close()
def cleanup_empty_dirs(root_dir: Path):
"""清理空目录"""
removed_count = 0
for dirpath, dirnames, filenames in os.walk(str(root_dir), topdown=False):
dir_path = Path(dirpath)
# 跳过根目录和 thumbnails 目录
if dir_path == root_dir or dir_path.name == "thumbnails":
continue
# 检查是否为空
try:
if dir_path.exists() and not any(dir_path.iterdir()):
dir_path.rmdir()
print(f"删除空目录: {dir_path}")
removed_count += 1
except Exception as e:
pass
if removed_count > 0:
print(f"清理完成,共删除 {removed_count} 个空目录")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="图片存储方案迁移工具")
parser.add_argument("--dry-run", action="store_true", help="试运行,不实际执行迁移")
args = parser.parse_args()
print("图片存储方案迁移工具")
print("="*60)
print(f"数据库: {DB_PATH}")
print(f"上传目录: {UPLOAD_DIR}")
print(f"模式: {'试运行' if args.dry_run else '实际执行'}")
print("="*60)
migrate_images(dry_run=args.dry_run)
@@ -0,0 +1,16 @@
import sqlite3
import json
conn = sqlite3.connect('cms.db')
c = conn.cursor()
# 把 news 模块的 content 从 {"count": 4} 改为 {"posts": [null, null, null, null]}
c.execute("UPDATE home_modules SET content = ? WHERE type = 'news'",
(json.dumps({"posts": [None, None, None, None]}),))
print(f'已更新 {c.rowcount} 条 news 模块')
conn.commit()
# 验证
c.execute("SELECT content FROM home_modules WHERE type='news'")
print('新 content:', c.fetchone()[0])
conn.close()
@@ -0,0 +1,192 @@
"""
新闻资讯模块迁移脚本
将老的新闻分类(news_categories)和新闻文章(news_posts)迁移到新的栏目(columns)和内容(articles)体系
执行方式: cd backend && .\venv-win\Scripts\python.exe migrate_news_to_columns.py
"""
import sys
import time
from sqlalchemy import create_engine, text
from pypinyin import lazy_pinyin
import re
DB_PATH = "sqlite:///cms.db"
engine = create_engine(DB_PATH)
def generate_slug(text: str) -> str:
"""将中文标题转为拼音 slug"""
if not text:
return f"article-{int(time.time())}"
py = lazy_pinyin(text, style=0)
slug = "-".join(py)
slug = re.sub(r"[^a-zA-Z0-9-]", "", slug).lower()
slug = re.sub(r"-+", "-", slug).strip("-")
return slug or f"article-{int(time.time())}"
def migrate():
with engine.connect() as conn:
trans = conn.begin()
try:
print("=" * 60)
print("新闻资讯模块迁移开始")
print("=" * 60)
# ========== 阶段 0: 清空现有数据 ==========
print("\n[阶段 0] 清空 columns 和 articles 表...")
# 先删 articles(外键依赖)
result = conn.execute(text("DELETE FROM articles"))
print(f" 已清空 articles 表")
# 再删 columns
result = conn.execute(text("DELETE FROM columns"))
print(f" 已清空 columns 表")
# ========== 阶段 1: 创建"新闻资讯"顶级栏目 ==========
print("\n[阶段 1] 创建'新闻资讯'顶级栏目...")
result = conn.execute(text("""
INSERT INTO columns (name, slug, description, parent_id, sort_order, is_enabled, display_mode, layout_style)
VALUES (:name, :slug, :description, NULL, 0, 1, 'list', 'left')
"""), {
"name": "新闻资讯",
"slug": "xin-wen-zi-xun",
"description": ""
})
# 获取刚插入的 ID
row = conn.execute(text("SELECT id FROM columns WHERE slug = 'xin-wen-zi-xun'")).fetchone()
news_top_id = row[0]
print(f" 顶级栏目 ID: {news_top_id}, slug: xin-wen-zi-xun")
# ========== 阶段 2: 迁移启用的 news_categories ==========
print("\n[阶段 2] 迁移启用的 news_categories(作为二级子栏目)...")
categories = conn.execute(text("""
SELECT id, name, slug, description, sort_order, is_enabled
FROM news_categories
WHERE is_enabled = 1
ORDER BY id
""")).fetchall()
cat_id_map = {} # old_id -> new_id
for cat in categories:
old_id, name, slug, description, sort_order, is_enabled = cat
result = conn.execute(text("""
INSERT INTO columns (name, slug, description, parent_id, sort_order, is_enabled, display_mode, layout_style)
VALUES (:name, :slug, :description, :parent_id, :sort_order, :is_enabled, 'list', 'left')
"""), {
"name": name,
"slug": slug,
"description": description or "",
"parent_id": news_top_id,
"sort_order": sort_order or 0,
"is_enabled": is_enabled
})
new_id = conn.execute(text("SELECT last_insert_rowid()")).fetchone()[0]
cat_id_map[old_id] = new_id
print(f" [{old_id}] {name} -> new_id={new_id}")
print(f" 共迁移 {len(cat_id_map)} 个子栏目")
# ========== 阶段 3: 迁移 news_posts ==========
print("\n[阶段 3] 迁移 news_posts(重新生成拼音 slug)...")
posts = conn.execute(text("""
SELECT id, title, slug, content, content_type, summary, cover_image,
category_id, is_published, is_draft, view_count, created_at, updated_at
FROM news_posts
ORDER BY id
""")).fetchall()
# 用于检测 slug 冲突
used_slugs = set()
slug_conflicts = 0
migrated_count = 0
skipped_count = 0
for post in posts:
(old_id, title, old_slug, content, content_type, summary, cover_image,
category_id, is_published, is_draft, view_count, created_at, updated_at) = post
# 只迁移已发布或有效的文章
# 状态映射: is_published=True -> published, 否则 draft
status = "published" if is_published else "draft"
# 重新生成拼音 slug
base_slug = generate_slug(title)
new_slug = base_slug
suffix = 1
while new_slug in used_slugs:
new_slug = f"{base_slug}-{suffix}"
suffix += 1
slug_conflicts += 1
used_slugs.add(new_slug)
# 栏目映射
new_column_id = cat_id_map.get(category_id)
if not new_column_id:
print(f" 跳过 post_id={old_id},category_id={category_id} 未找到映射")
skipped_count += 1
continue
conn.execute(text("""
INSERT INTO articles (title, slug, content, content_type, summary, cover_image,
column_id, author, status, sort_order, view_count, created_at, updated_at)
VALUES (:title, :slug, :content, :content_type, :summary, :cover_image,
:column_id, '', :status, 0, :view_count, :created_at, :updated_at)
"""), {
"title": title,
"slug": new_slug,
"content": content or "",
"content_type": content_type or "markdown",
"summary": summary or "",
"cover_image": cover_image or "",
"column_id": new_column_id,
"status": status,
"view_count": view_count or 0,
"created_at": created_at,
"updated_at": updated_at
})
migrated_count += 1
if migrated_count % 50 == 0:
print(f" 已迁移 {migrated_count} / {len(posts)} 篇...")
print(f" 共迁移 {migrated_count} 篇,跳过 {skipped_count} 篇,slug 冲突解决 {slug_conflicts} 次")
# ========== 阶段 4: 验证 ==========
print("\n[阶段 4] 验证迁移结果...")
col_count = conn.execute(text("SELECT COUNT(*) FROM columns")).fetchone()[0]
art_count = conn.execute(text("SELECT COUNT(*) FROM articles")).fetchone()[0]
top_col = conn.execute(text("SELECT id, name, slug FROM columns WHERE parent_id IS NULL")).fetchone()
child_cols = conn.execute(text("SELECT COUNT(*) FROM columns WHERE parent_id IS NOT NULL")).fetchone()[0]
print(f" columns 表总数: {col_count}")
print(f" 顶级栏目: {top_col}")
print(f" 子栏目数: {child_cols}")
print(f" articles 表总数: {art_count}")
# 每个子栏目下的文章数
print("\n 各子栏目文章分布:")
for row in conn.execute(text("""
SELECT c.name, COUNT(a.id) as cnt
FROM columns c
LEFT JOIN articles a ON a.column_id = c.id
WHERE c.parent_id IS NOT NULL
GROUP BY c.id
ORDER BY cnt DESC
""")):
print(f" {row[0]}: {row[1]} 篇")
trans.commit()
print("\n" + "=" * 60)
print("迁移成功完成!")
print("=" * 60)
except Exception as e:
trans.rollback()
print(f"\n迁移失败,已回滚: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
migrate()
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
数据库迁移脚本:将旧的上传路径迁移到新的存储结构
使用同步 SQLAlchemy 进行数据库操作
"""
import sys
from pathlib import Path
# Add backend to path
sys.path.insert(0, str(Path(__file__).parent))
from sqlalchemy import create_engine, select, text
from sqlalchemy.orm import sessionmaker
from app.config import get_settings
settings = get_settings()
# 创建同步引擎
engine = create_engine(settings.DATABASE_URL.replace('+aiosqlite', ''), echo=False)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def migrate_image_paths():
"""迁移 Image 表中的路径"""
session = SessionLocal()
try:
# 获取所有图片
result = session.execute(text("SELECT id, file_path, thumb_path FROM images"))
images = result.fetchall()
updated_count = 0
for img in images:
img_id = img[0]
old_file_path = img[1]
old_thumb_path = img[2]
new_file_path = old_file_path
new_thumb_path = old_thumb_path
# 迁移 file_path
if old_file_path and not old_file_path.startswith('/storage/'):
if old_file_path.startswith('/'):
# 旧格式: /filename.jpg -> /storage/admin/images/filename.jpg
new_file_path = f"/storage/admin/images{old_file_path}"
else:
# 如果没有前导斜杠,添加一个
new_file_path = f"/storage/admin/images/{old_file_path}"
# 迁移 thumb_path
if old_thumb_path and not old_thumb_path.startswith('/storage/'):
if old_thumb_path.startswith('/'):
# 旧格式: /thumbnails/filename.jpg -> /storage/admin/images/thumbnails/filename.jpg
if old_thumb_path.startswith('/thumbnails/'):
new_thumb_path = f"/storage/admin/images{old_thumb_path}"
else:
new_thumb_path = f"/storage/admin/images/thumbnails{old_thumb_path}"
else:
new_thumb_path = f"/storage/admin/images/thumbnails/{old_thumb_path}"
if old_file_path != new_file_path or old_thumb_path != new_thumb_path:
session.execute(
text("UPDATE images SET file_path = :fp, thumb_path = :tp WHERE id = :id"),
{"fp": new_file_path, "tp": new_thumb_path, "id": img_id}
)
print(f" Image {img_id}: {old_file_path} -> {new_file_path}")
if old_thumb_path != new_thumb_path:
print(f" Thumb: {old_thumb_path} -> {new_thumb_path}")
updated_count += 1
session.commit()
print(f"\n✅ 迁移了 {updated_count} 张图片的路径")
finally:
session.close()
def migrate_news_cover_images():
"""迁移 NewsPost 表中的封面图片路径"""
session = SessionLocal()
try:
# 获取所有新闻文章
result = session.execute(text("SELECT id, cover_image FROM news_posts"))
posts = result.fetchall()
updated_count = 0
for post in posts:
post_id = post[0]
old_cover_image = post[1]
new_cover_image = old_cover_image
# 迁移 cover_image
if old_cover_image and not old_cover_image.startswith('/storage/'):
if old_cover_image.startswith('/'):
if old_cover_image.startswith('/uploads/'):
# 已经是 /uploads/ 格式,迁移到新的 storage 路径
filename = old_cover_image.replace('/uploads/', '')
new_cover_image = f"/storage/admin/images/{filename}"
else:
# 旧格式: /filename.jpg -> /storage/admin/images/filename.jpg
new_cover_image = f"/storage/admin/images{old_cover_image}"
else:
new_cover_image = f"/storage/admin/images/{old_cover_image}"
if old_cover_image != new_cover_image:
session.execute(
text("UPDATE news_posts SET cover_image = :ci WHERE id = :id"),
{"ci": new_cover_image, "id": post_id}
)
print(f" Post {post_id}: {old_cover_image} -> {new_cover_image}")
updated_count += 1
session.commit()
print(f"\n✅ 迁移了 {updated_count} 篇新闻的封面图片路径")
finally:
session.close()
def main():
print("=" * 60)
print("开始数据库路径迁移...")
print("=" * 60)
migrate_image_paths()
migrate_news_cover_images()
print("\n" + "=" * 60)
print("✅ 数据库迁移完成!")
print("=" * 60)
if __name__ == "__main__":
main()
@@ -0,0 +1,10 @@
fastapi==0.115.0
uvicorn==0.32.0
sqlalchemy==2.0.36
pydantic-settings==2.6.1
python-jose==3.3.0
passlib==1.7.4
python-multipart==0.0.17
qrcode==8.0
pillow==11.0.0
aiofiles==24.1.0
@@ -0,0 +1,12 @@
from sqlalchemy import create_engine, text
engine = create_engine('sqlite:///cms.db')
with engine.connect() as conn:
conn.execute(text("""
UPDATE columns
SET description = '了解飞点健康最新动态、行业资讯和健康知识',
icon_name = 'Heart'
WHERE slug = 'xin-wen-zi-xun'
"""))
conn.commit()
print('Updated 新闻资讯 column: description + icon_name=Heart')
@@ -0,0 +1,5 @@
import requests
res = requests.get('http://localhost:10003/api/public/columns/xin-wen-zi-xun')
print('Status:', res.status_code)
print('Response:', res.json())
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""测试通过点击分页按钮翻页"""
from playwright.sync_api import sync_playwright
from bs4 import BeautifulSoup
import re
def test_click_pagination(menu_id=7):
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width': 1920, 'height': 1080})
url = f"https://niaodaifu.cn/health-information?menu_id={menu_id}"
print(f"[打开] {url}")
page.goto(url, wait_until='networkidle', timeout=30000)
page.wait_for_timeout(3000)
def get_article_ids():
soup = BeautifulSoup(page.content(), 'html.parser')
ids = []
for m in soup.find_all('div', class_='media'):
a = m.find('a', class_='media-heading')
if a:
href = a.get('href', '')
match = re.search(r'article_id=(\d+)', href)
if match:
ids.append(match.group(1))
return ids
ids1 = get_article_ids()
print(f"第1页 article_ids: {ids1}")
# 查找下一页按钮
print("\n[分页按钮信息]")
next_btn = page.query_selector('button.btn-next')
if next_btn:
print(f" 找到下一页按钮: {next_btn.get_attribute('class')}")
print(f" 是否禁用: {next_btn.get_attribute('disabled')}")
else:
print(" 未找到 button.btn-next")
# 查找所有页码按钮
pager_btns = page.query_selector_all('.el-pager li')
print(f" 页码按钮数量: {len(pager_btns)}")
for btn in pager_btns:
print(f" - class={btn.get_attribute('class')} text={btn.text_content()}")
# 点击第2页
if pager_btns and len(pager_btns) >= 2:
print("\n[点击第2页]")
pager_btns[1].click()
page.wait_for_timeout(3000)
ids2 = get_article_ids()
print(f"第2页 article_ids: {ids2}")
if ids2 != ids1:
print("*** 点击分页成功!内容不同 ***")
else:
print("!!! 点击后内容未变 !!!")
elif next_btn:
print("\n[点击下一页按钮]")
next_btn.click()
page.wait_for_timeout(3000)
ids2 = get_article_ids()
print(f"第2页 article_ids: {ids2}")
if ids2 != ids1:
print("*** 点击下一页成功!内容不同 ***")
else:
print("!!! 点击后内容未变 !!!")
browser.close()
if __name__ == '__main__':
test_click_pagination()
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""测试单篇文章采集"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from batch_crawl import (
get_article_detail, download_image, html_to_markdown,
clean_markdown, insert_image_to_db, save_article, DB_PATH
)
from playwright.sync_api import sync_playwright
import sqlite3
TEST_URL = "https://niaodaifu.cn/health-information/detail?menu_id=15&article_id=992"
CATEGORY_ID = 2 # 科普文章
print("=" * 60)
print("测试采集单篇文章")
print("=" * 60)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
print(f"\n1. 获取详情页: {TEST_URL}")
detail = get_article_detail(page, TEST_URL)
print(f" 标题: {detail['title']}")
print(f" 来源: {detail['source']}")
print(f" 时间: {detail['time']}")
print(f" 正文HTML长度: {len(detail['content_html'])}")
print("\n2. 转换 Markdown...")
from bs4 import BeautifulSoup
soup = BeautifulSoup(detail['content_html'], 'html.parser')
content_div = soup.find('div', class_='article-content')
downloaded_images = {}
content_md = html_to_markdown(content_div, TEST_URL, downloaded_images)
content_md = clean_markdown(content_md)
print(f" Markdown长度: {len(content_md)}")
print(f" 下载图片数: {len(downloaded_images)}")
for src, path in downloaded_images.items():
print(f" {src} -> {path}")
print("\n3. Markdown 前500字符预览:")
print("-" * 40)
print(content_md[:500])
print("-" * 40)
print("\n4. 保存到数据库...")
article = {
'article_id': 992,
'title': detail['title'] or '2024吴试纸春节放假安排',
'href': TEST_URL,
'cover_url': 'https://image.niaodaifu.cn/news/2024-01/d756f6171be42e0000a7f1b7375193da.jpg',
'time': detail['time'],
}
cover_path, cover_info = download_image(article['cover_url'])
print(f" 封面图: {cover_path}")
if cover_info:
conn = sqlite3.connect(DB_PATH)
img_id = insert_image_to_db(conn, cover_info)
conn.commit()
conn.close()
print(f" 封面图入库 ID: {img_id}")
post_id = save_article(article, detail, CATEGORY_ID, cover_path, content_md)
print(f" 文章入库 ID: {post_id}")
browser.close()
print("\n测试完成!")
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""测试分页URL格式"""
from playwright.sync_api import sync_playwright
from bs4 import BeautifulSoup
import re
def test_pagination(menu_id=7):
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width': 1920, 'height': 1080})
# 先打开第1页
url1 = f"https://niaodaifu.cn/health-information?menu_id={menu_id}"
print(f"[第1页] GET {url1}")
page.goto(url1, wait_until='networkidle', timeout=30000)
page.wait_for_timeout(3000)
soup1 = BeautifulSoup(page.content(), 'html.parser')
medias1 = soup1.find_all('div', class_='media')
ids1 = []
for m in medias1:
a = m.find('a', class_='media-heading')
if a:
href = a.get('href', '')
match = re.search(r'article_id=(\d+)', href)
if match:
ids1.append(match.group(1))
print(f" 第1页 article_ids: {ids1}")
# 尝试几种分页URL格式
formats = [
f"https://niaodaifu.cn/health-information?menu_id={menu_id}&page=2",
f"https://niaodaifu.cn/health-information?menu_id={menu_id}&pageNo=2",
f"https://niaodaifu.cn/health-information?menu_id={menu_id}&pageIndex=2",
]
for url2 in formats:
print(f"\n[第2页] GET {url2}")
page.goto(url2, wait_until='networkidle', timeout=30000)
page.wait_for_timeout(3000)
soup2 = BeautifulSoup(page.content(), 'html.parser')
medias2 = soup2.find_all('div', class_='media')
ids2 = []
for m in medias2:
a = m.find('a', class_='media-heading')
if a:
href = a.get('href', '')
match = re.search(r'article_id=(\d+)', href)
if match:
ids2.append(match.group(1))
print(f" 第2页 article_ids: {ids2}")
if ids2 != ids1:
print(f" *** 分页有效!与第1页不同 ***")
break
else:
print(f" !!! 分页无效,与第1页相同 !!!")
# 查看分页组件HTML
print("\n\n[分页组件HTML]")
pager = soup1.find(class_=re.compile('el-pagination'))
if pager:
print(pager.prettify()[:1000])
else:
print("未找到 el-pagination")
browser.close()
if __name__ == '__main__':
test_pagination()
@@ -0,0 +1,28 @@
import sqlite3, json
conn = sqlite3.connect('cms.db')
c = conn.cursor()
# 模拟前端保存的 content
new_content = {
"posts": [
{
"id": 3,
"title": "这5项指标反映糖尿病病情,却被很多糖友忽略了!",
"summary": "summary text",
"cover_image": "/storage/admin/images/202604/94c0d16c2996bb3ad55b6d9f09a7d7e8.png",
"slug": "这5项指标反映糖尿病病情却被很多糖友忽略了",
"category_name": "科普文章",
"created_at": "2026-04-20"
},
None, None, None
]
}
c.execute("UPDATE home_modules SET content = ? WHERE type = 'news'",
(json.dumps(new_content, ensure_ascii=False),))
conn.commit()
print(f'updated {c.rowcount} rows')
c.execute("SELECT content FROM home_modules WHERE type='news'")
print('saved:', c.fetchone()[0])
conn.close()
@@ -0,0 +1,164 @@
/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */
/* Greenlet object interface */
#ifndef Py_GREENLETOBJECT_H
#define Py_GREENLETOBJECT_H
#include <Python.h>
#ifdef __cplusplus
extern "C" {
#endif
/* This is deprecated and undocumented. It does not change. */
#define GREENLET_VERSION "1.0.0"
#ifndef GREENLET_MODULE
#define implementation_ptr_t void*
#endif
typedef struct _greenlet {
PyObject_HEAD
PyObject* weakreflist;
PyObject* dict;
implementation_ptr_t pimpl;
} PyGreenlet;
#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type))
/* C API functions */
/* Total number of symbols that are exported */
#define PyGreenlet_API_pointers 12
#define PyGreenlet_Type_NUM 0
#define PyExc_GreenletError_NUM 1
#define PyExc_GreenletExit_NUM 2
#define PyGreenlet_New_NUM 3
#define PyGreenlet_GetCurrent_NUM 4
#define PyGreenlet_Throw_NUM 5
#define PyGreenlet_Switch_NUM 6
#define PyGreenlet_SetParent_NUM 7
#define PyGreenlet_MAIN_NUM 8
#define PyGreenlet_STARTED_NUM 9
#define PyGreenlet_ACTIVE_NUM 10
#define PyGreenlet_GET_PARENT_NUM 11
#ifndef GREENLET_MODULE
/* This section is used by modules that uses the greenlet C API */
static void** _PyGreenlet_API = NULL;
# define PyGreenlet_Type \
(*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM])
# define PyExc_GreenletError \
((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM])
# define PyExc_GreenletExit \
((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM])
/*
* PyGreenlet_New(PyObject *args)
*
* greenlet.greenlet(run, parent=None)
*/
# define PyGreenlet_New \
(*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \
_PyGreenlet_API[PyGreenlet_New_NUM])
/*
* PyGreenlet_GetCurrent(void)
*
* greenlet.getcurrent()
*/
# define PyGreenlet_GetCurrent \
(*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM])
/*
* PyGreenlet_Throw(
* PyGreenlet *greenlet,
* PyObject *typ,
* PyObject *val,
* PyObject *tb)
*
* g.throw(...)
*/
# define PyGreenlet_Throw \
(*(PyObject * (*)(PyGreenlet * self, \
PyObject * typ, \
PyObject * val, \
PyObject * tb)) \
_PyGreenlet_API[PyGreenlet_Throw_NUM])
/*
* PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args)
*
* g.switch(*args, **kwargs)
*/
# define PyGreenlet_Switch \
(*(PyObject * \
(*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \
_PyGreenlet_API[PyGreenlet_Switch_NUM])
/*
* PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent)
*
* g.parent = new_parent
*/
# define PyGreenlet_SetParent \
(*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \
_PyGreenlet_API[PyGreenlet_SetParent_NUM])
/*
* PyGreenlet_GetParent(PyObject* greenlet)
*
* return greenlet.parent;
*
* This could return NULL even if there is no exception active.
* If it does not return NULL, you are responsible for decrementing the
* reference count.
*/
# define PyGreenlet_GetParent \
(*(PyGreenlet* (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_GET_PARENT_NUM])
/*
* deprecated, undocumented alias.
*/
# define PyGreenlet_GET_PARENT PyGreenlet_GetParent
# define PyGreenlet_MAIN \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_MAIN_NUM])
# define PyGreenlet_STARTED \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_STARTED_NUM])
# define PyGreenlet_ACTIVE \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_ACTIVE_NUM])
/* Macro that imports greenlet and initializes C API */
/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we
keep the older definition to be sure older code that might have a copy of
the header still works. */
# define PyGreenlet_Import() \
{ \
_PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \
}
#endif /* GREENLET_MODULE */
#ifdef __cplusplus
}
#endif
#endif /* !Py_GREENLETOBJECT_H */
@@ -0,0 +1,293 @@
from __future__ import annotations
import os
from io import BytesIO
from typing import IO
from . import ExifTags, Image, ImageFile
try:
from . import _avif
SUPPORTED = True
except ImportError:
SUPPORTED = False
# Decoder options as module globals, until there is a way to pass parameters
# to Image.open (see https://github.com/python-pillow/Pillow/issues/569)
DECODE_CODEC_CHOICE = "auto"
DEFAULT_MAX_THREADS = 0
def get_codec_version(codec_name: str) -> str | None:
versions = _avif.codec_versions()
for version in versions.split(", "):
if version.split(" [")[0] == codec_name:
return version.split(":")[-1].split(" ")[0]
return None
def _accept(prefix: bytes) -> bool | str:
if prefix[4:8] != b"ftyp":
return False
major_brand = prefix[8:12]
if major_brand in (
# coding brands
b"avif",
b"avis",
# We accept files with AVIF container brands; we can't yet know if
# the ftyp box has the correct compatible brands, but if it doesn't
# then the plugin will raise a SyntaxError which Pillow will catch
# before moving on to the next plugin that accepts the file.
#
# Also, because this file might not actually be an AVIF file, we
# don't raise an error if AVIF support isn't properly compiled.
b"mif1",
b"msf1",
):
if not SUPPORTED:
return (
"image file could not be identified because AVIF support not installed"
)
return True
return False
def _get_default_max_threads() -> int:
if DEFAULT_MAX_THREADS:
return DEFAULT_MAX_THREADS
if hasattr(os, "sched_getaffinity"):
return len(os.sched_getaffinity(0))
else:
return os.cpu_count() or 1
class AvifImageFile(ImageFile.ImageFile):
format = "AVIF"
format_description = "AVIF image"
__frame = -1
def _open(self) -> None:
if not SUPPORTED:
msg = "image file could not be opened because AVIF support not installed"
raise SyntaxError(msg)
if DECODE_CODEC_CHOICE != "auto" and not _avif.decoder_codec_available(
DECODE_CODEC_CHOICE
):
msg = "Invalid opening codec"
raise ValueError(msg)
assert self.fp is not None
self._decoder = _avif.AvifDecoder(
self.fp.read(),
DECODE_CODEC_CHOICE,
_get_default_max_threads(),
)
# Get info from decoder
self._size, self.n_frames, self._mode, icc, exif, exif_orientation, xmp = (
self._decoder.get_info()
)
self.is_animated = self.n_frames > 1
if icc:
self.info["icc_profile"] = icc
if xmp:
self.info["xmp"] = xmp
if exif_orientation != 1 or exif:
exif_data = Image.Exif()
if exif:
exif_data.load(exif)
original_orientation = exif_data.get(ExifTags.Base.Orientation, 1)
else:
original_orientation = 1
if exif_orientation != original_orientation:
exif_data[ExifTags.Base.Orientation] = exif_orientation
exif = exif_data.tobytes()
if exif:
self.info["exif"] = exif
self.seek(0)
def seek(self, frame: int) -> None:
if not self._seek_check(frame):
return
# Set tile
self.__frame = frame
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, self.mode)]
def load(self) -> Image.core.PixelAccess | None:
if self.tile:
# We need to load the image data for this frame
data, timescale, pts_in_timescales, duration_in_timescales = (
self._decoder.get_frame(self.__frame)
)
self.info["timestamp"] = round(1000 * (pts_in_timescales / timescale))
self.info["duration"] = round(1000 * (duration_in_timescales / timescale))
if self.fp and self._exclusive_fp:
self.fp.close()
self.fp = BytesIO(data)
return super().load()
def load_seek(self, pos: int) -> None:
pass
def tell(self) -> int:
return self.__frame
def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
_save(im, fp, filename, save_all=True)
def _save(
im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False
) -> None:
info = im.encoderinfo.copy()
if save_all:
append_images = list(info.get("append_images", []))
else:
append_images = []
total = 0
for ims in [im] + append_images:
total += getattr(ims, "n_frames", 1)
quality = info.get("quality", 75)
if not isinstance(quality, int) or quality < 0 or quality > 100:
msg = "Invalid quality setting"
raise ValueError(msg)
duration = info.get("duration", 0)
subsampling = info.get("subsampling", "4:2:0")
speed = info.get("speed", 6)
max_threads = info.get("max_threads", _get_default_max_threads())
codec = info.get("codec", "auto")
if codec != "auto" and not _avif.encoder_codec_available(codec):
msg = "Invalid saving codec"
raise ValueError(msg)
range_ = info.get("range", "full")
tile_rows_log2 = info.get("tile_rows", 0)
tile_cols_log2 = info.get("tile_cols", 0)
alpha_premultiplied = bool(info.get("alpha_premultiplied", False))
autotiling = bool(info.get("autotiling", tile_rows_log2 == tile_cols_log2 == 0))
icc_profile = info.get("icc_profile", im.info.get("icc_profile"))
exif_orientation = 1
if exif := info.get("exif"):
if isinstance(exif, Image.Exif):
exif_data = exif
else:
exif_data = Image.Exif()
exif_data.load(exif)
if ExifTags.Base.Orientation in exif_data:
exif_orientation = exif_data.pop(ExifTags.Base.Orientation)
exif = exif_data.tobytes() if exif_data else b""
elif isinstance(exif, Image.Exif):
exif = exif_data.tobytes()
xmp = info.get("xmp")
if isinstance(xmp, str):
xmp = xmp.encode("utf-8")
advanced = info.get("advanced")
if advanced is not None:
if isinstance(advanced, dict):
advanced = advanced.items()
try:
advanced = tuple(advanced)
except TypeError:
invalid = True
else:
invalid = any(not isinstance(v, tuple) or len(v) != 2 for v in advanced)
if invalid:
msg = (
"advanced codec options must be a dict of key-value string "
"pairs or a series of key-value two-tuples"
)
raise ValueError(msg)
# Setup the AVIF encoder
enc = _avif.AvifEncoder(
im.size,
subsampling,
quality,
speed,
max_threads,
codec,
range_,
tile_rows_log2,
tile_cols_log2,
alpha_premultiplied,
autotiling,
icc_profile or b"",
exif or b"",
exif_orientation,
xmp or b"",
advanced,
)
# Add each frame
frame_idx = 0
frame_duration = 0
cur_idx = im.tell()
is_single_frame = total == 1
try:
for ims in [im] + append_images:
# Get number of frames in this image
nfr = getattr(ims, "n_frames", 1)
for idx in range(nfr):
ims.seek(idx)
# Make sure image mode is supported
frame = ims
rawmode = ims.mode
if ims.mode not in {"RGB", "RGBA"}:
rawmode = "RGBA" if ims.has_transparency_data else "RGB"
frame = ims.convert(rawmode)
# Update frame duration
if isinstance(duration, (list, tuple)):
frame_duration = duration[frame_idx]
else:
frame_duration = duration
# Append the frame to the animation encoder
enc.add(
frame.tobytes("raw", rawmode),
frame_duration,
frame.size,
rawmode,
is_single_frame,
)
# Update frame index
frame_idx += 1
if not save_all:
break
finally:
im.seek(cur_idx)
# Get the final output from the encoder
data = enc.finish()
if data is None:
msg = "cannot write file as AVIF (encoder returned None)"
raise OSError(msg)
fp.write(data)
Image.register_open(AvifImageFile.format, AvifImageFile, _accept)
if SUPPORTED:
Image.register_save(AvifImageFile.format, _save)
Image.register_save_all(AvifImageFile.format, _save_all)
Image.register_extensions(AvifImageFile.format, [".avif", ".avifs"])
Image.register_mime(AvifImageFile.format, "image/avif")
@@ -0,0 +1,123 @@
#
# The Python Imaging Library
# $Id$
#
# bitmap distribution font (bdf) file parser
#
# history:
# 1996-05-16 fl created (as bdf2pil)
# 1997-08-25 fl converted to FontFile driver
# 2001-05-25 fl removed bogus __init__ call
# 2002-11-20 fl robustification (from Kevin Cazabon, Dmitry Vasiliev)
# 2003-04-22 fl more robustification (from Graham Dumpleton)
#
# Copyright (c) 1997-2003 by Secret Labs AB.
# Copyright (c) 1997-2003 by Fredrik Lundh.
#
# See the README file for information on usage and redistribution.
#
"""
Parse X Bitmap Distribution Format (BDF)
"""
from __future__ import annotations
from typing import BinaryIO
from . import FontFile, Image
def bdf_char(
f: BinaryIO,
) -> (
tuple[
str,
int,
tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]],
Image.Image,
]
| None
):
# skip to STARTCHAR
while True:
s = f.readline()
if not s:
return None
if s.startswith(b"STARTCHAR"):
break
id = s[9:].strip().decode("ascii")
# load symbol properties
props = {}
while True:
s = f.readline()
if not s or s.startswith(b"BITMAP"):
break
i = s.find(b" ")
props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii")
# load bitmap
bitmap = bytearray()
while True:
s = f.readline()
if not s or s.startswith(b"ENDCHAR"):
break
bitmap += s[:-1]
# The word BBX
# followed by the width in x (BBw), height in y (BBh),
# and x and y displacement (BBxoff0, BByoff0)
# of the lower left corner from the origin of the character.
width, height, x_disp, y_disp = (int(p) for p in props["BBX"].split())
# The word DWIDTH
# followed by the width in x and y of the character in device pixels.
dwx, dwy = (int(p) for p in props["DWIDTH"].split())
bbox = (
(dwx, dwy),
(x_disp, -y_disp - height, width + x_disp, -y_disp),
(0, 0, width, height),
)
try:
im = Image.frombytes("1", (width, height), bitmap, "hex", "1")
except ValueError:
# deal with zero-width characters
im = Image.new("1", (width, height))
return id, int(props["ENCODING"]), bbox, im
class BdfFontFile(FontFile.FontFile):
"""Font file plugin for the X11 BDF format."""
def __init__(self, fp: BinaryIO) -> None:
super().__init__()
s = fp.readline()
if not s.startswith(b"STARTFONT 2.1"):
msg = "not a valid BDF file"
raise SyntaxError(msg)
props = {}
comments = []
while True:
s = fp.readline()
if not s or s.startswith(b"ENDPROPERTIES"):
break
i = s.find(b" ")
props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii")
if s[:i] in [b"COMMENT", b"COPYRIGHT"]:
if s.find(b"LogicalFontDescription") < 0:
comments.append(s[i + 1 : -1].decode("ascii"))
while True:
c = bdf_char(fp)
if not c:
break
id, ch, (xy, dst, src), im = c
if 0 <= ch < len(self.glyph):
self.glyph[ch] = xy, dst, src, im
@@ -0,0 +1,498 @@
"""
Blizzard Mipmap Format (.blp)
Jerome Leclanche <jerome@leclan.ch>
The contents of this file are hereby released in the public domain (CC0)
Full text of the CC0 license:
https://creativecommons.org/publicdomain/zero/1.0/
BLP1 files, used mostly in Warcraft III, are not fully supported.
All types of BLP2 files used in World of Warcraft are supported.
The BLP file structure consists of a header, up to 16 mipmaps of the
texture
Texture sizes must be powers of two, though the two dimensions do
not have to be equal; 512x256 is valid, but 512x200 is not.
The first mipmap (mipmap #0) is the full size image; each subsequent
mipmap halves both dimensions. The final mipmap should be 1x1.
BLP files come in many different flavours:
* JPEG-compressed (type == 0) - only supported for BLP1.
* RAW images (type == 1, encoding == 1). Each mipmap is stored as an
array of 8-bit values, one per pixel, left to right, top to bottom.
Each value is an index to the palette.
* DXT-compressed (type == 1, encoding == 2):
- DXT1 compression is used if alpha_encoding == 0.
- An additional alpha bit is used if alpha_depth == 1.
- DXT3 compression is used if alpha_encoding == 1.
- DXT5 compression is used if alpha_encoding == 7.
"""
from __future__ import annotations
import abc
import os
import struct
from enum import IntEnum
from io import BytesIO
from typing import IO
from . import Image, ImageFile
class Format(IntEnum):
JPEG = 0
class Encoding(IntEnum):
UNCOMPRESSED = 1
DXT = 2
UNCOMPRESSED_RAW_BGRA = 3
class AlphaEncoding(IntEnum):
DXT1 = 0
DXT3 = 1
DXT5 = 7
def unpack_565(i: int) -> tuple[int, int, int]:
return ((i >> 11) & 0x1F) << 3, ((i >> 5) & 0x3F) << 2, (i & 0x1F) << 3
def decode_dxt1(
data: bytes, alpha: bool = False
) -> tuple[bytearray, bytearray, bytearray, bytearray]:
"""
input: one "row" of data (i.e. will produce 4*width pixels)
"""
blocks = len(data) // 8 # number of blocks in row
ret = (bytearray(), bytearray(), bytearray(), bytearray())
for block_index in range(blocks):
# Decode next 8-byte block.
idx = block_index * 8
color0, color1, bits = struct.unpack_from("<HHI", data, idx)
r0, g0, b0 = unpack_565(color0)
r1, g1, b1 = unpack_565(color1)
# Decode this block into 4x4 pixels
# Accumulate the results onto our 4 row accumulators
for j in range(4):
for i in range(4):
# get next control op and generate a pixel
control = bits & 3
bits = bits >> 2
a = 0xFF
if control == 0:
r, g, b = r0, g0, b0
elif control == 1:
r, g, b = r1, g1, b1
elif control == 2:
if color0 > color1:
r = (2 * r0 + r1) // 3
g = (2 * g0 + g1) // 3
b = (2 * b0 + b1) // 3
else:
r = (r0 + r1) // 2
g = (g0 + g1) // 2
b = (b0 + b1) // 2
elif control == 3:
if color0 > color1:
r = (2 * r1 + r0) // 3
g = (2 * g1 + g0) // 3
b = (2 * b1 + b0) // 3
else:
r, g, b, a = 0, 0, 0, 0
if alpha:
ret[j].extend([r, g, b, a])
else:
ret[j].extend([r, g, b])
return ret
def decode_dxt3(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]:
"""
input: one "row" of data (i.e. will produce 4*width pixels)
"""
blocks = len(data) // 16 # number of blocks in row
ret = (bytearray(), bytearray(), bytearray(), bytearray())
for block_index in range(blocks):
idx = block_index * 16
block = data[idx : idx + 16]
# Decode next 16-byte block.
bits = struct.unpack_from("<8B", block)
color0, color1 = struct.unpack_from("<HH", block, 8)
(code,) = struct.unpack_from("<I", block, 12)
r0, g0, b0 = unpack_565(color0)
r1, g1, b1 = unpack_565(color1)
for j in range(4):
high = False # Do we want the higher bits?
for i in range(4):
alphacode_index = (4 * j + i) // 2
a = bits[alphacode_index]
if high:
high = False
a >>= 4
else:
high = True
a &= 0xF
a *= 17 # We get a value between 0 and 15
color_code = (code >> 2 * (4 * j + i)) & 0x03
if color_code == 0:
r, g, b = r0, g0, b0
elif color_code == 1:
r, g, b = r1, g1, b1
elif color_code == 2:
r = (2 * r0 + r1) // 3
g = (2 * g0 + g1) // 3
b = (2 * b0 + b1) // 3
elif color_code == 3:
r = (2 * r1 + r0) // 3
g = (2 * g1 + g0) // 3
b = (2 * b1 + b0) // 3
ret[j].extend([r, g, b, a])
return ret
def decode_dxt5(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]:
"""
input: one "row" of data (i.e. will produce 4 * width pixels)
"""
blocks = len(data) // 16 # number of blocks in row
ret = (bytearray(), bytearray(), bytearray(), bytearray())
for block_index in range(blocks):
idx = block_index * 16
block = data[idx : idx + 16]
# Decode next 16-byte block.
a0, a1 = struct.unpack_from("<BB", block)
bits = struct.unpack_from("<6B", block, 2)
alphacode1 = bits[2] | (bits[3] << 8) | (bits[4] << 16) | (bits[5] << 24)
alphacode2 = bits[0] | (bits[1] << 8)
color0, color1 = struct.unpack_from("<HH", block, 8)
(code,) = struct.unpack_from("<I", block, 12)
r0, g0, b0 = unpack_565(color0)
r1, g1, b1 = unpack_565(color1)
for j in range(4):
for i in range(4):
# get next control op and generate a pixel
alphacode_index = 3 * (4 * j + i)
if alphacode_index <= 12:
alphacode = (alphacode2 >> alphacode_index) & 0x07
elif alphacode_index == 15:
alphacode = (alphacode2 >> 15) | ((alphacode1 << 1) & 0x06)
else: # alphacode_index >= 18 and alphacode_index <= 45
alphacode = (alphacode1 >> (alphacode_index - 16)) & 0x07
if alphacode == 0:
a = a0
elif alphacode == 1:
a = a1
elif a0 > a1:
a = ((8 - alphacode) * a0 + (alphacode - 1) * a1) // 7
elif alphacode == 6:
a = 0
elif alphacode == 7:
a = 255
else:
a = ((6 - alphacode) * a0 + (alphacode - 1) * a1) // 5
color_code = (code >> 2 * (4 * j + i)) & 0x03
if color_code == 0:
r, g, b = r0, g0, b0
elif color_code == 1:
r, g, b = r1, g1, b1
elif color_code == 2:
r = (2 * r0 + r1) // 3
g = (2 * g0 + g1) // 3
b = (2 * b0 + b1) // 3
elif color_code == 3:
r = (2 * r1 + r0) // 3
g = (2 * g1 + g0) // 3
b = (2 * b1 + b0) // 3
ret[j].extend([r, g, b, a])
return ret
class BLPFormatError(NotImplementedError):
pass
def _accept(prefix: bytes) -> bool:
return prefix.startswith((b"BLP1", b"BLP2"))
class BlpImageFile(ImageFile.ImageFile):
"""
Blizzard Mipmap Format
"""
format = "BLP"
format_description = "Blizzard Mipmap Format"
def _open(self) -> None:
assert self.fp is not None
self.magic = self.fp.read(4)
if not _accept(self.magic):
msg = f"Bad BLP magic {repr(self.magic)}"
raise BLPFormatError(msg)
compression = struct.unpack("<i", self.fp.read(4))[0]
if self.magic == b"BLP1":
alpha = struct.unpack("<I", self.fp.read(4))[0] != 0
else:
encoding = struct.unpack("<b", self.fp.read(1))[0]
alpha = struct.unpack("<b", self.fp.read(1))[0] != 0
alpha_encoding = struct.unpack("<b", self.fp.read(1))[0]
self.fp.seek(1, os.SEEK_CUR) # mips
self._size = struct.unpack("<II", self.fp.read(8))
args: tuple[int, int, bool] | tuple[int, int, bool, int]
if self.magic == b"BLP1":
encoding = struct.unpack("<i", self.fp.read(4))[0]
self.fp.seek(4, os.SEEK_CUR) # subtype
args = (compression, encoding, alpha)
offset = 28
else:
args = (compression, encoding, alpha, alpha_encoding)
offset = 20
decoder = self.magic.decode()
self._mode = "RGBA" if alpha else "RGB"
self.tile = [ImageFile._Tile(decoder, (0, 0) + self.size, offset, args)]
class _BLPBaseDecoder(abc.ABC, ImageFile.PyDecoder):
_pulls_fd = True
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
try:
self._read_header()
self._load()
except struct.error as e:
msg = "Truncated BLP file"
raise OSError(msg) from e
return -1, 0
@abc.abstractmethod
def _load(self) -> None:
pass
def _read_header(self) -> None:
self._offsets = struct.unpack("<16I", self._safe_read(16 * 4))
self._lengths = struct.unpack("<16I", self._safe_read(16 * 4))
def _safe_read(self, length: int) -> bytes:
assert self.fd is not None
return ImageFile._safe_read(self.fd, length)
def _read_palette(self) -> list[tuple[int, int, int, int]]:
ret = []
for i in range(256):
try:
b, g, r, a = struct.unpack("<4B", self._safe_read(4))
except struct.error:
break
ret.append((b, g, r, a))
return ret
def _read_bgra(
self, palette: list[tuple[int, int, int, int]], alpha: bool
) -> bytearray:
data = bytearray()
_data = BytesIO(self._safe_read(self._lengths[0]))
while True:
try:
(offset,) = struct.unpack("<B", _data.read(1))
except struct.error:
break
b, g, r, a = palette[offset]
d: tuple[int, ...] = (r, g, b)
if alpha:
d += (a,)
data.extend(d)
return data
class BLP1Decoder(_BLPBaseDecoder):
def _load(self) -> None:
self._compression, self._encoding, alpha = self.args
if self._compression == Format.JPEG:
self._decode_jpeg_stream()
elif self._compression == 1:
if self._encoding in (4, 5):
palette = self._read_palette()
data = self._read_bgra(palette, alpha)
self.set_as_raw(data)
else:
msg = f"Unsupported BLP encoding {repr(self._encoding)}"
raise BLPFormatError(msg)
else:
msg = f"Unsupported BLP compression {repr(self._encoding)}"
raise BLPFormatError(msg)
def _decode_jpeg_stream(self) -> None:
from .JpegImagePlugin import JpegImageFile
(jpeg_header_size,) = struct.unpack("<I", self._safe_read(4))
jpeg_header = self._safe_read(jpeg_header_size)
assert self.fd is not None
self._safe_read(self._offsets[0] - self.fd.tell()) # What IS this?
data = self._safe_read(self._lengths[0])
data = jpeg_header + data
image = JpegImageFile(BytesIO(data))
Image._decompression_bomb_check(image.size)
if image.mode == "CMYK":
args = image.tile[0].args
assert isinstance(args, tuple)
image.tile = [image.tile[0]._replace(args=(args[0], "CMYK"))]
self.set_as_raw(image.convert("RGB").tobytes(), "BGR")
class BLP2Decoder(_BLPBaseDecoder):
def _load(self) -> None:
self._compression, self._encoding, alpha, self._alpha_encoding = self.args
palette = self._read_palette()
assert self.fd is not None
self.fd.seek(self._offsets[0])
if self._compression == 1:
# Uncompressed or DirectX compression
if self._encoding == Encoding.UNCOMPRESSED:
data = self._read_bgra(palette, alpha)
elif self._encoding == Encoding.DXT:
data = bytearray()
if self._alpha_encoding == AlphaEncoding.DXT1:
linesize = (self.state.xsize + 3) // 4 * 8
for yb in range((self.state.ysize + 3) // 4):
for d in decode_dxt1(self._safe_read(linesize), alpha):
data += d
elif self._alpha_encoding == AlphaEncoding.DXT3:
linesize = (self.state.xsize + 3) // 4 * 16
for yb in range((self.state.ysize + 3) // 4):
for d in decode_dxt3(self._safe_read(linesize)):
data += d
elif self._alpha_encoding == AlphaEncoding.DXT5:
linesize = (self.state.xsize + 3) // 4 * 16
for yb in range((self.state.ysize + 3) // 4):
for d in decode_dxt5(self._safe_read(linesize)):
data += d
else:
msg = f"Unsupported alpha encoding {repr(self._alpha_encoding)}"
raise BLPFormatError(msg)
else:
msg = f"Unknown BLP encoding {repr(self._encoding)}"
raise BLPFormatError(msg)
else:
msg = f"Unknown BLP compression {repr(self._compression)}"
raise BLPFormatError(msg)
self.set_as_raw(data)
class BLPEncoder(ImageFile.PyEncoder):
_pushes_fd = True
def _write_palette(self) -> bytes:
data = b""
assert self.im is not None
palette = self.im.getpalette("RGBA", "RGBA")
for i in range(len(palette) // 4):
r, g, b, a = palette[i * 4 : (i + 1) * 4]
data += struct.pack("<4B", b, g, r, a)
while len(data) < 256 * 4:
data += b"\x00" * 4
return data
def encode(self, bufsize: int) -> tuple[int, int, bytes]:
palette_data = self._write_palette()
offset = 20 + 16 * 4 * 2 + len(palette_data)
data = struct.pack("<16I", offset, *((0,) * 15))
assert self.im is not None
w, h = self.im.size
data += struct.pack("<16I", w * h, *((0,) * 15))
data += palette_data
for y in range(h):
for x in range(w):
data += struct.pack("<B", self.im.getpixel((x, y)))
return len(data), 0, data
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if im.mode != "P":
msg = "Unsupported BLP image mode"
raise ValueError(msg)
magic = b"BLP1" if im.encoderinfo.get("blp_version") == "BLP1" else b"BLP2"
fp.write(magic)
assert im.palette is not None
fp.write(struct.pack("<i", 1)) # Uncompressed or DirectX compression
alpha_depth = 1 if im.palette.mode == "RGBA" else 0
if magic == b"BLP1":
fp.write(struct.pack("<L", alpha_depth))
else:
fp.write(struct.pack("<b", Encoding.UNCOMPRESSED))
fp.write(struct.pack("<b", alpha_depth))
fp.write(struct.pack("<b", 0)) # alpha encoding
fp.write(struct.pack("<b", 0)) # mips
fp.write(struct.pack("<II", *im.size))
if magic == b"BLP1":
fp.write(struct.pack("<i", 5))
fp.write(struct.pack("<i", 0))
ImageFile._save(im, fp, [ImageFile._Tile("BLP", (0, 0) + im.size, 0, im.mode)])
Image.register_open(BlpImageFile.format, BlpImageFile, _accept)
Image.register_extension(BlpImageFile.format, ".blp")
Image.register_decoder("BLP1", BLP1Decoder)
Image.register_decoder("BLP2", BLP2Decoder)
Image.register_save(BlpImageFile.format, _save)
Image.register_encoder("BLP", BLPEncoder)
@@ -0,0 +1,514 @@
#
# The Python Imaging Library.
# $Id$
#
# BMP file handler
#
# Windows (and OS/2) native bitmap storage format.
#
# history:
# 1995-09-01 fl Created
# 1996-04-30 fl Added save
# 1997-08-27 fl Fixed save of 1-bit images
# 1998-03-06 fl Load P images as L where possible
# 1998-07-03 fl Load P images as 1 where possible
# 1998-12-29 fl Handle small palettes
# 2002-12-30 fl Fixed load of 1-bit palette images
# 2003-04-21 fl Fixed load of 1-bit monochrome images
# 2003-04-23 fl Added limited support for BI_BITFIELDS compression
#
# Copyright (c) 1997-2003 by Secret Labs AB
# Copyright (c) 1995-2003 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import os
from typing import IO, Any
from . import Image, ImageFile, ImagePalette
from ._binary import i16le as i16
from ._binary import i32le as i32
from ._binary import o8
from ._binary import o16le as o16
from ._binary import o32le as o32
#
# --------------------------------------------------------------------
# Read BMP file
BIT2MODE = {
# bits => mode, rawmode
1: ("P", "P;1"),
4: ("P", "P;4"),
8: ("P", "P"),
16: ("RGB", "BGR;15"),
24: ("RGB", "BGR"),
32: ("RGB", "BGRX"),
}
USE_RAW_ALPHA = False
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"BM")
def _dib_accept(prefix: bytes) -> bool:
return i32(prefix) in [12, 40, 52, 56, 64, 108, 124]
# =============================================================================
# Image plugin for the Windows BMP format.
# =============================================================================
class BmpImageFile(ImageFile.ImageFile):
"""Image plugin for the Windows Bitmap format (BMP)"""
# ------------------------------------------------------------- Description
format_description = "Windows Bitmap"
format = "BMP"
# -------------------------------------------------- BMP Compression values
COMPRESSIONS = {"RAW": 0, "RLE8": 1, "RLE4": 2, "BITFIELDS": 3, "JPEG": 4, "PNG": 5}
for k, v in COMPRESSIONS.items():
vars()[k] = v
def _bitmap(self, header: int = 0, offset: int = 0) -> None:
"""Read relevant info about the BMP"""
assert self.fp is not None
read, seek = self.fp.read, self.fp.seek
if header:
seek(header)
# read bmp header size @offset 14 (this is part of the header size)
file_info: dict[str, bool | int | tuple[int, ...]] = {
"header_size": i32(read(4)),
"direction": -1,
}
# -------------------- If requested, read header at a specific position
# read the rest of the bmp header, without its size
assert isinstance(file_info["header_size"], int)
header_data = ImageFile._safe_read(self.fp, file_info["header_size"] - 4)
# ------------------------------- Windows Bitmap v2, IBM OS/2 Bitmap v1
# ----- This format has different offsets because of width/height types
# 12: BITMAPCOREHEADER/OS21XBITMAPHEADER
if file_info["header_size"] == 12:
file_info["width"] = i16(header_data, 0)
file_info["height"] = i16(header_data, 2)
file_info["planes"] = i16(header_data, 4)
file_info["bits"] = i16(header_data, 6)
file_info["compression"] = self.COMPRESSIONS["RAW"]
file_info["palette_padding"] = 3
# --------------------------------------------- Windows Bitmap v3 to v5
# 40: BITMAPINFOHEADER
# 52: BITMAPV2HEADER
# 56: BITMAPV3HEADER
# 64: BITMAPCOREHEADER2/OS22XBITMAPHEADER
# 108: BITMAPV4HEADER
# 124: BITMAPV5HEADER
elif file_info["header_size"] in (40, 52, 56, 64, 108, 124):
file_info["y_flip"] = header_data[7] == 0xFF
file_info["direction"] = 1 if file_info["y_flip"] else -1
file_info["width"] = i32(header_data, 0)
file_info["height"] = (
i32(header_data, 4)
if not file_info["y_flip"]
else 2**32 - i32(header_data, 4)
)
file_info["planes"] = i16(header_data, 8)
file_info["bits"] = i16(header_data, 10)
file_info["compression"] = i32(header_data, 12)
# byte size of pixel data
file_info["data_size"] = i32(header_data, 16)
file_info["pixels_per_meter"] = (
i32(header_data, 20),
i32(header_data, 24),
)
file_info["colors"] = i32(header_data, 28)
file_info["palette_padding"] = 4
assert isinstance(file_info["pixels_per_meter"], tuple)
self.info["dpi"] = tuple(x / 39.3701 for x in file_info["pixels_per_meter"])
if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]:
masks = ["r_mask", "g_mask", "b_mask"]
if len(header_data) >= 48:
if len(header_data) >= 52:
masks.append("a_mask")
else:
file_info["a_mask"] = 0x0
for idx, mask in enumerate(masks):
file_info[mask] = i32(header_data, 36 + idx * 4)
else:
# 40 byte headers only have the three components in the
# bitfields masks, ref:
# https://msdn.microsoft.com/en-us/library/windows/desktop/dd183376(v=vs.85).aspx
# See also
# https://github.com/python-pillow/Pillow/issues/1293
# There is a 4th component in the RGBQuad, in the alpha
# location, but it is listed as a reserved component,
# and it is not generally an alpha channel
file_info["a_mask"] = 0x0
for mask in masks:
file_info[mask] = i32(read(4))
assert isinstance(file_info["r_mask"], int)
assert isinstance(file_info["g_mask"], int)
assert isinstance(file_info["b_mask"], int)
assert isinstance(file_info["a_mask"], int)
file_info["rgb_mask"] = (
file_info["r_mask"],
file_info["g_mask"],
file_info["b_mask"],
)
file_info["rgba_mask"] = (
file_info["r_mask"],
file_info["g_mask"],
file_info["b_mask"],
file_info["a_mask"],
)
else:
msg = f"Unsupported BMP header type ({file_info['header_size']})"
raise OSError(msg)
# ------------------ Special case : header is reported 40, which
# ---------------------- is shorter than real size for bpp >= 16
assert isinstance(file_info["width"], int)
assert isinstance(file_info["height"], int)
self._size = file_info["width"], file_info["height"]
# ------- If color count was not found in the header, compute from bits
assert isinstance(file_info["bits"], int)
if not file_info.get("colors", 0):
file_info["colors"] = 1 << file_info["bits"]
assert isinstance(file_info["palette_padding"], int)
assert isinstance(file_info["colors"], int)
if offset == 14 + file_info["header_size"] and file_info["bits"] <= 8:
offset += file_info["palette_padding"] * file_info["colors"]
# ---------------------- Check bit depth for unusual unsupported values
self._mode, raw_mode = BIT2MODE.get(file_info["bits"], ("", ""))
if not self.mode:
msg = f"Unsupported BMP pixel depth ({file_info['bits']})"
raise OSError(msg)
# ---------------- Process BMP with Bitfields compression (not palette)
decoder_name = "raw"
if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]:
SUPPORTED: dict[int, list[tuple[int, ...]]] = {
32: [
(0xFF0000, 0xFF00, 0xFF, 0x0),
(0xFF000000, 0xFF0000, 0xFF00, 0x0),
(0xFF000000, 0xFF00, 0xFF, 0x0),
(0xFF000000, 0xFF0000, 0xFF00, 0xFF),
(0xFF, 0xFF00, 0xFF0000, 0xFF000000),
(0xFF0000, 0xFF00, 0xFF, 0xFF000000),
(0xFF000000, 0xFF00, 0xFF, 0xFF0000),
(0x0, 0x0, 0x0, 0x0),
],
24: [(0xFF0000, 0xFF00, 0xFF)],
16: [(0xF800, 0x7E0, 0x1F), (0x7C00, 0x3E0, 0x1F)],
}
MASK_MODES = {
(32, (0xFF0000, 0xFF00, 0xFF, 0x0)): "BGRX",
(32, (0xFF000000, 0xFF0000, 0xFF00, 0x0)): "XBGR",
(32, (0xFF000000, 0xFF00, 0xFF, 0x0)): "BGXR",
(32, (0xFF000000, 0xFF0000, 0xFF00, 0xFF)): "ABGR",
(32, (0xFF, 0xFF00, 0xFF0000, 0xFF000000)): "RGBA",
(32, (0xFF0000, 0xFF00, 0xFF, 0xFF000000)): "BGRA",
(32, (0xFF000000, 0xFF00, 0xFF, 0xFF0000)): "BGAR",
(32, (0x0, 0x0, 0x0, 0x0)): "BGRA",
(24, (0xFF0000, 0xFF00, 0xFF)): "BGR",
(16, (0xF800, 0x7E0, 0x1F)): "BGR;16",
(16, (0x7C00, 0x3E0, 0x1F)): "BGR;15",
}
if file_info["bits"] in SUPPORTED:
if (
file_info["bits"] == 32
and file_info["rgba_mask"] in SUPPORTED[file_info["bits"]]
):
assert isinstance(file_info["rgba_mask"], tuple)
raw_mode = MASK_MODES[(file_info["bits"], file_info["rgba_mask"])]
self._mode = "RGBA" if "A" in raw_mode else self.mode
elif (
file_info["bits"] in (24, 16)
and file_info["rgb_mask"] in SUPPORTED[file_info["bits"]]
):
assert isinstance(file_info["rgb_mask"], tuple)
raw_mode = MASK_MODES[(file_info["bits"], file_info["rgb_mask"])]
else:
msg = "Unsupported BMP bitfields layout"
raise OSError(msg)
else:
msg = "Unsupported BMP bitfields layout"
raise OSError(msg)
elif file_info["compression"] == self.COMPRESSIONS["RAW"]:
if file_info["bits"] == 32 and (
header == 22 or USE_RAW_ALPHA # 32-bit .cur offset
):
raw_mode, self._mode = "BGRA", "RGBA"
elif file_info["compression"] in (
self.COMPRESSIONS["RLE8"],
self.COMPRESSIONS["RLE4"],
):
decoder_name = "bmp_rle"
else:
msg = f"Unsupported BMP compression ({file_info['compression']})"
raise OSError(msg)
# --------------- Once the header is processed, process the palette/LUT
if self.mode == "P": # Paletted for 1, 4 and 8 bit images
# ---------------------------------------------------- 1-bit images
if not (0 < file_info["colors"] <= 65536):
msg = f"Unsupported BMP Palette size ({file_info['colors']})"
raise OSError(msg)
else:
padding = file_info["palette_padding"]
palette = read(padding * file_info["colors"])
grayscale = True
indices = (
(0, 255)
if file_info["colors"] == 2
else list(range(file_info["colors"]))
)
# ----------------- Check if grayscale and ignore palette if so
for ind, val in enumerate(indices):
rgb = palette[ind * padding : ind * padding + 3]
if rgb != o8(val) * 3:
grayscale = False
# ------- If all colors are gray, white or black, ditch palette
if grayscale:
self._mode = "1" if file_info["colors"] == 2 else "L"
raw_mode = self.mode
else:
self._mode = "P"
self.palette = ImagePalette.raw(
"BGRX" if padding == 4 else "BGR", palette
)
# ---------------------------- Finally set the tile data for the plugin
self.info["compression"] = file_info["compression"]
args: list[Any] = [raw_mode]
if decoder_name == "bmp_rle":
args.append(file_info["compression"] == self.COMPRESSIONS["RLE4"])
else:
assert isinstance(file_info["width"], int)
args.append(((file_info["width"] * file_info["bits"] + 31) >> 3) & (~3))
args.append(file_info["direction"])
self.tile = [
ImageFile._Tile(
decoder_name,
(0, 0, file_info["width"], file_info["height"]),
offset or self.fp.tell(),
tuple(args),
)
]
def _open(self) -> None:
"""Open file, check magic number and read header"""
# read 14 bytes: magic number, filesize, reserved, header final offset
assert self.fp is not None
head_data = self.fp.read(14)
# choke if the file does not have the required magic bytes
if not _accept(head_data):
msg = "Not a BMP file"
raise SyntaxError(msg)
# read the start position of the BMP image data (u32)
offset = i32(head_data, 10)
# load bitmap information (offset=raster info)
self._bitmap(offset=offset)
class BmpRleDecoder(ImageFile.PyDecoder):
_pulls_fd = True
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
assert self.fd is not None
rle4 = self.args[1]
data = bytearray()
x = 0
dest_length = self.state.xsize * self.state.ysize
while len(data) < dest_length:
pixels = self.fd.read(1)
byte = self.fd.read(1)
if not pixels or not byte:
break
num_pixels = pixels[0]
if num_pixels:
# encoded mode
if x + num_pixels > self.state.xsize:
# Too much data for row
num_pixels = max(0, self.state.xsize - x)
if rle4:
first_pixel = o8(byte[0] >> 4)
second_pixel = o8(byte[0] & 0x0F)
for index in range(num_pixels):
if index % 2 == 0:
data += first_pixel
else:
data += second_pixel
else:
data += byte * num_pixels
x += num_pixels
else:
if byte[0] == 0:
# end of line
while len(data) % self.state.xsize != 0:
data += b"\x00"
x = 0
elif byte[0] == 1:
# end of bitmap
break
elif byte[0] == 2:
# delta
bytes_read = self.fd.read(2)
if len(bytes_read) < 2:
break
right, up = bytes_read
data += b"\x00" * (right + up * self.state.xsize)
x = len(data) % self.state.xsize
else:
# absolute mode
if rle4:
# 2 pixels per byte
byte_count = byte[0] // 2
bytes_read = self.fd.read(byte_count)
for byte_read in bytes_read:
data += o8(byte_read >> 4)
data += o8(byte_read & 0x0F)
else:
byte_count = byte[0]
bytes_read = self.fd.read(byte_count)
data += bytes_read
if len(bytes_read) < byte_count:
break
x += byte[0]
# align to 16-bit word boundary
if self.fd.tell() % 2 != 0:
self.fd.seek(1, os.SEEK_CUR)
rawmode = "L" if self.mode == "L" else "P"
self.set_as_raw(bytes(data), rawmode, (0, self.args[-1]))
return -1, 0
# =============================================================================
# Image plugin for the DIB format (BMP alias)
# =============================================================================
class DibImageFile(BmpImageFile):
format = "DIB"
format_description = "Windows Bitmap"
def _open(self) -> None:
self._bitmap()
#
# --------------------------------------------------------------------
# Write BMP file
SAVE = {
"1": ("1", 1, 2),
"L": ("L", 8, 256),
"P": ("P", 8, 256),
"RGB": ("BGR", 24, 0),
"RGBA": ("BGRA", 32, 0),
}
def _dib_save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
_save(im, fp, filename, False)
def _save(
im: Image.Image, fp: IO[bytes], filename: str | bytes, bitmap_header: bool = True
) -> None:
try:
rawmode, bits, colors = SAVE[im.mode]
except KeyError as e:
msg = f"cannot write mode {im.mode} as BMP"
raise OSError(msg) from e
info = im.encoderinfo
dpi = info.get("dpi", (96, 96))
# 1 meter == 39.3701 inches
ppm = tuple(int(x * 39.3701 + 0.5) for x in dpi)
stride = ((im.size[0] * bits + 7) // 8 + 3) & (~3)
header = 40 # or 64 for OS/2 version 2
image = stride * im.size[1]
if im.mode == "1":
palette = b"".join(o8(i) * 3 + b"\x00" for i in (0, 255))
elif im.mode == "L":
palette = b"".join(o8(i) * 3 + b"\x00" for i in range(256))
elif im.mode == "P":
palette = im.im.getpalette("RGB", "BGRX")
colors = len(palette) // 4
else:
palette = None
# bitmap header
if bitmap_header:
offset = 14 + header + colors * 4
file_size = offset + image
if file_size > 2**32 - 1:
msg = "File size is too large for the BMP format"
raise ValueError(msg)
fp.write(
b"BM" # file type (magic)
+ o32(file_size) # file size
+ o32(0) # reserved
+ o32(offset) # image data offset
)
# bitmap info header
fp.write(
o32(header) # info header size
+ o32(im.size[0]) # width
+ o32(im.size[1]) # height
+ o16(1) # planes
+ o16(bits) # depth
+ o32(0) # compression (0=uncompressed)
+ o32(image) # size of bitmap
+ o32(ppm[0]) # resolution
+ o32(ppm[1]) # resolution
+ o32(colors) # colors used
+ o32(colors) # colors important
)
fp.write(b"\0" * (header - 40)) # padding (for OS/2 format)
if palette:
fp.write(palette)
ImageFile._save(
im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, stride, -1))]
)
#
# --------------------------------------------------------------------
# Registry
Image.register_open(BmpImageFile.format, BmpImageFile, _accept)
Image.register_save(BmpImageFile.format, _save)
Image.register_extension(BmpImageFile.format, ".bmp")
Image.register_mime(BmpImageFile.format, "image/bmp")
Image.register_decoder("bmp_rle", BmpRleDecoder)
Image.register_open(DibImageFile.format, DibImageFile, _dib_accept)
Image.register_save(DibImageFile.format, _dib_save)
Image.register_extension(DibImageFile.format, ".dib")
Image.register_mime(DibImageFile.format, "image/bmp")
@@ -0,0 +1,72 @@
#
# The Python Imaging Library
# $Id$
#
# BUFR stub adapter
#
# Copyright (c) 1996-2003 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import os
from typing import IO
from . import Image, ImageFile
_handler = None
def register_handler(handler: ImageFile.StubHandler | None) -> None:
"""
Install application-specific BUFR image handler.
:param handler: Handler object.
"""
global _handler
_handler = handler
# --------------------------------------------------------------------
# Image adapter
def _accept(prefix: bytes) -> bool:
return prefix.startswith((b"BUFR", b"ZCZC"))
class BufrStubImageFile(ImageFile.StubImageFile):
format = "BUFR"
format_description = "BUFR"
def _open(self) -> None:
assert self.fp is not None
if not _accept(self.fp.read(4)):
msg = "Not a BUFR file"
raise SyntaxError(msg)
self.fp.seek(-4, os.SEEK_CUR)
# make something up
self._mode = "F"
self._size = 1, 1
def _load(self) -> ImageFile.StubHandler | None:
return _handler
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if _handler is None or not hasattr(_handler, "save"):
msg = "BUFR save handler not installed"
raise OSError(msg)
_handler.save(im, fp, filename)
# --------------------------------------------------------------------
# Registry
Image.register_open(BufrStubImageFile.format, BufrStubImageFile, _accept)
Image.register_save(BufrStubImageFile.format, _save)
Image.register_extension(BufrStubImageFile.format, ".bufr")
@@ -0,0 +1,173 @@
#
# The Python Imaging Library.
# $Id$
#
# a class to read from a container file
#
# History:
# 1995-06-18 fl Created
# 1995-09-07 fl Added readline(), readlines()
#
# Copyright (c) 1997-2001 by Secret Labs AB
# Copyright (c) 1995 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import io
from collections.abc import Iterable
from typing import IO, AnyStr, NoReturn
class ContainerIO(IO[AnyStr]):
"""
A file object that provides read access to a part of an existing
file (for example a TAR file).
"""
def __init__(self, file: IO[AnyStr], offset: int, length: int) -> None:
"""
Create file object.
:param file: Existing file.
:param offset: Start of region, in bytes.
:param length: Size of region, in bytes.
"""
self.fh: IO[AnyStr] = file
self.pos = 0
self.offset = offset
self.length = length
self.fh.seek(offset)
##
# Always false.
def isatty(self) -> bool:
return False
def seekable(self) -> bool:
return True
def seek(self, offset: int, mode: int = io.SEEK_SET) -> int:
"""
Move file pointer.
:param offset: Offset in bytes.
:param mode: Starting position. Use 0 for beginning of region, 1
for current offset, and 2 for end of region. You cannot move
the pointer outside the defined region.
:returns: Offset from start of region, in bytes.
"""
if mode == 1:
self.pos = self.pos + offset
elif mode == 2:
self.pos = self.length + offset
else:
self.pos = offset
# clamp
self.pos = max(0, min(self.pos, self.length))
self.fh.seek(self.offset + self.pos)
return self.pos
def tell(self) -> int:
"""
Get current file pointer.
:returns: Offset from start of region, in bytes.
"""
return self.pos
def readable(self) -> bool:
return True
def read(self, n: int = -1) -> AnyStr:
"""
Read data.
:param n: Number of bytes to read. If omitted, zero or negative,
read until end of region.
:returns: An 8-bit string.
"""
if n > 0:
n = min(n, self.length - self.pos)
else:
n = self.length - self.pos
if n <= 0: # EOF
return b"" if "b" in self.fh.mode else "" # type: ignore[return-value]
self.pos = self.pos + n
return self.fh.read(n)
def readline(self, n: int = -1) -> AnyStr:
"""
Read a line of text.
:param n: Number of bytes to read. If omitted, zero or negative,
read until end of line.
:returns: An 8-bit string.
"""
s: AnyStr = b"" if "b" in self.fh.mode else "" # type: ignore[assignment]
newline_character = b"\n" if "b" in self.fh.mode else "\n"
while True:
c = self.read(1)
if not c:
break
s = s + c
if c == newline_character or len(s) == n:
break
return s
def readlines(self, n: int | None = -1) -> list[AnyStr]:
"""
Read multiple lines of text.
:param n: Number of lines to read. If omitted, zero, negative or None,
read until end of region.
:returns: A list of 8-bit strings.
"""
lines = []
while True:
s = self.readline()
if not s:
break
lines.append(s)
if len(lines) == n:
break
return lines
def writable(self) -> bool:
return False
def write(self, b: AnyStr) -> NoReturn:
raise NotImplementedError()
def writelines(self, lines: Iterable[AnyStr]) -> NoReturn:
raise NotImplementedError()
def truncate(self, size: int | None = None) -> int:
raise NotImplementedError()
def __enter__(self) -> ContainerIO[AnyStr]:
return self
def __exit__(self, *args: object) -> None:
self.close()
def __iter__(self) -> ContainerIO[AnyStr]:
return self
def __next__(self) -> AnyStr:
line = self.readline()
if not line:
msg = "end of region"
raise StopIteration(msg)
return line
def fileno(self) -> int:
return self.fh.fileno()
def flush(self) -> None:
self.fh.flush()
def close(self) -> None:
self.fh.close()
@@ -0,0 +1,75 @@
#
# The Python Imaging Library.
# $Id$
#
# Windows Cursor support for PIL
#
# notes:
# uses BmpImagePlugin.py to read the bitmap data.
#
# history:
# 96-05-27 fl Created
#
# Copyright (c) Secret Labs AB 1997.
# Copyright (c) Fredrik Lundh 1996.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
from . import BmpImagePlugin, Image
from ._binary import i16le as i16
from ._binary import i32le as i32
#
# --------------------------------------------------------------------
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"\0\0\2\0")
##
# Image plugin for Windows Cursor files.
class CurImageFile(BmpImagePlugin.BmpImageFile):
format = "CUR"
format_description = "Windows Cursor"
def _open(self) -> None:
assert self.fp is not None
offset = self.fp.tell()
# check magic
s = self.fp.read(6)
if not _accept(s):
msg = "not a CUR file"
raise SyntaxError(msg)
# pick the largest cursor in the file
m = b""
for i in range(i16(s, 4)):
s = self.fp.read(16)
if not m:
m = s
elif s[0] > m[0] and s[1] > m[1]:
m = s
if not m:
msg = "No cursors were found"
raise TypeError(msg)
# load as bitmap
self._bitmap(i32(m, 12) + offset)
# patch up the bitmap height
self._size = self.size[0], self.size[1] // 2
self.tile = [self.tile[0]._replace(extents=(0, 0) + self.size)]
#
# --------------------------------------------------------------------
Image.register_open(CurImageFile.format, CurImageFile, _accept)
Image.register_extension(CurImageFile.format, ".cur")
@@ -0,0 +1,84 @@
#
# The Python Imaging Library.
# $Id$
#
# DCX file handling
#
# DCX is a container file format defined by Intel, commonly used
# for fax applications. Each DCX file consists of a directory
# (a list of file offsets) followed by a set of (usually 1-bit)
# PCX files.
#
# History:
# 1995-09-09 fl Created
# 1996-03-20 fl Properly derived from PcxImageFile.
# 1998-07-15 fl Renamed offset attribute to avoid name clash
# 2002-07-30 fl Fixed file handling
#
# Copyright (c) 1997-98 by Secret Labs AB.
# Copyright (c) 1995-96 by Fredrik Lundh.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
from . import Image
from ._binary import i32le as i32
from ._util import DeferredError
from .PcxImagePlugin import PcxImageFile
MAGIC = 0x3ADE68B1 # QUIZ: what's this value, then?
def _accept(prefix: bytes) -> bool:
return len(prefix) >= 4 and i32(prefix) == MAGIC
##
# Image plugin for the Intel DCX format.
class DcxImageFile(PcxImageFile):
format = "DCX"
format_description = "Intel DCX"
_close_exclusive_fp_after_loading = False
def _open(self) -> None:
# Header
assert self.fp is not None
s = self.fp.read(4)
if not _accept(s):
msg = "not a DCX file"
raise SyntaxError(msg)
# Component directory
self._offset = []
for i in range(1024):
offset = i32(self.fp.read(4))
if not offset:
break
self._offset.append(offset)
self._fp = self.fp
self.frame = -1
self.n_frames = len(self._offset)
self.is_animated = self.n_frames > 1
self.seek(0)
def seek(self, frame: int) -> None:
if not self._seek_check(frame):
return
if isinstance(self._fp, DeferredError):
raise self._fp.ex
self.frame = frame
self.fp = self._fp
self.fp.seek(self._offset[frame])
PcxImageFile._open(self)
def tell(self) -> int:
return self.frame
Image.register_open(DcxImageFile.format, DcxImageFile, _accept)
Image.register_extension(DcxImageFile.format, ".dcx")
@@ -0,0 +1,625 @@
"""
A Pillow plugin for .dds files (S3TC-compressed aka DXTC)
Jerome Leclanche <jerome@leclan.ch>
Documentation:
https://web.archive.org/web/20170802060935/http://oss.sgi.com/projects/ogl-sample/registry/EXT/texture_compression_s3tc.txt
The contents of this file are hereby released in the public domain (CC0)
Full text of the CC0 license:
https://creativecommons.org/publicdomain/zero/1.0/
"""
from __future__ import annotations
import struct
import sys
from enum import IntEnum, IntFlag
from typing import IO
from . import Image, ImageFile, ImagePalette
from ._binary import i32le as i32
from ._binary import o8
from ._binary import o32le as o32
# Magic ("DDS ")
DDS_MAGIC = 0x20534444
# DDS flags
class DDSD(IntFlag):
CAPS = 0x1
HEIGHT = 0x2
WIDTH = 0x4
PITCH = 0x8
PIXELFORMAT = 0x1000
MIPMAPCOUNT = 0x20000
LINEARSIZE = 0x80000
DEPTH = 0x800000
# DDS caps
class DDSCAPS(IntFlag):
COMPLEX = 0x8
TEXTURE = 0x1000
MIPMAP = 0x400000
class DDSCAPS2(IntFlag):
CUBEMAP = 0x200
CUBEMAP_POSITIVEX = 0x400
CUBEMAP_NEGATIVEX = 0x800
CUBEMAP_POSITIVEY = 0x1000
CUBEMAP_NEGATIVEY = 0x2000
CUBEMAP_POSITIVEZ = 0x4000
CUBEMAP_NEGATIVEZ = 0x8000
VOLUME = 0x200000
# Pixel Format
class DDPF(IntFlag):
ALPHAPIXELS = 0x1
ALPHA = 0x2
FOURCC = 0x4
PALETTEINDEXED8 = 0x20
RGB = 0x40
LUMINANCE = 0x20000
# dxgiformat.h
class DXGI_FORMAT(IntEnum):
UNKNOWN = 0
R32G32B32A32_TYPELESS = 1
R32G32B32A32_FLOAT = 2
R32G32B32A32_UINT = 3
R32G32B32A32_SINT = 4
R32G32B32_TYPELESS = 5
R32G32B32_FLOAT = 6
R32G32B32_UINT = 7
R32G32B32_SINT = 8
R16G16B16A16_TYPELESS = 9
R16G16B16A16_FLOAT = 10
R16G16B16A16_UNORM = 11
R16G16B16A16_UINT = 12
R16G16B16A16_SNORM = 13
R16G16B16A16_SINT = 14
R32G32_TYPELESS = 15
R32G32_FLOAT = 16
R32G32_UINT = 17
R32G32_SINT = 18
R32G8X24_TYPELESS = 19
D32_FLOAT_S8X24_UINT = 20
R32_FLOAT_X8X24_TYPELESS = 21
X32_TYPELESS_G8X24_UINT = 22
R10G10B10A2_TYPELESS = 23
R10G10B10A2_UNORM = 24
R10G10B10A2_UINT = 25
R11G11B10_FLOAT = 26
R8G8B8A8_TYPELESS = 27
R8G8B8A8_UNORM = 28
R8G8B8A8_UNORM_SRGB = 29
R8G8B8A8_UINT = 30
R8G8B8A8_SNORM = 31
R8G8B8A8_SINT = 32
R16G16_TYPELESS = 33
R16G16_FLOAT = 34
R16G16_UNORM = 35
R16G16_UINT = 36
R16G16_SNORM = 37
R16G16_SINT = 38
R32_TYPELESS = 39
D32_FLOAT = 40
R32_FLOAT = 41
R32_UINT = 42
R32_SINT = 43
R24G8_TYPELESS = 44
D24_UNORM_S8_UINT = 45
R24_UNORM_X8_TYPELESS = 46
X24_TYPELESS_G8_UINT = 47
R8G8_TYPELESS = 48
R8G8_UNORM = 49
R8G8_UINT = 50
R8G8_SNORM = 51
R8G8_SINT = 52
R16_TYPELESS = 53
R16_FLOAT = 54
D16_UNORM = 55
R16_UNORM = 56
R16_UINT = 57
R16_SNORM = 58
R16_SINT = 59
R8_TYPELESS = 60
R8_UNORM = 61
R8_UINT = 62
R8_SNORM = 63
R8_SINT = 64
A8_UNORM = 65
R1_UNORM = 66
R9G9B9E5_SHAREDEXP = 67
R8G8_B8G8_UNORM = 68
G8R8_G8B8_UNORM = 69
BC1_TYPELESS = 70
BC1_UNORM = 71
BC1_UNORM_SRGB = 72
BC2_TYPELESS = 73
BC2_UNORM = 74
BC2_UNORM_SRGB = 75
BC3_TYPELESS = 76
BC3_UNORM = 77
BC3_UNORM_SRGB = 78
BC4_TYPELESS = 79
BC4_UNORM = 80
BC4_SNORM = 81
BC5_TYPELESS = 82
BC5_UNORM = 83
BC5_SNORM = 84
B5G6R5_UNORM = 85
B5G5R5A1_UNORM = 86
B8G8R8A8_UNORM = 87
B8G8R8X8_UNORM = 88
R10G10B10_XR_BIAS_A2_UNORM = 89
B8G8R8A8_TYPELESS = 90
B8G8R8A8_UNORM_SRGB = 91
B8G8R8X8_TYPELESS = 92
B8G8R8X8_UNORM_SRGB = 93
BC6H_TYPELESS = 94
BC6H_UF16 = 95
BC6H_SF16 = 96
BC7_TYPELESS = 97
BC7_UNORM = 98
BC7_UNORM_SRGB = 99
AYUV = 100
Y410 = 101
Y416 = 102
NV12 = 103
P010 = 104
P016 = 105
OPAQUE_420 = 106
YUY2 = 107
Y210 = 108
Y216 = 109
NV11 = 110
AI44 = 111
IA44 = 112
P8 = 113
A8P8 = 114
B4G4R4A4_UNORM = 115
P208 = 130
V208 = 131
V408 = 132
SAMPLER_FEEDBACK_MIN_MIP_OPAQUE = 189
SAMPLER_FEEDBACK_MIP_REGION_USED_OPAQUE = 190
class D3DFMT(IntEnum):
UNKNOWN = 0
R8G8B8 = 20
A8R8G8B8 = 21
X8R8G8B8 = 22
R5G6B5 = 23
X1R5G5B5 = 24
A1R5G5B5 = 25
A4R4G4B4 = 26
R3G3B2 = 27
A8 = 28
A8R3G3B2 = 29
X4R4G4B4 = 30
A2B10G10R10 = 31
A8B8G8R8 = 32
X8B8G8R8 = 33
G16R16 = 34
A2R10G10B10 = 35
A16B16G16R16 = 36
A8P8 = 40
P8 = 41
L8 = 50
A8L8 = 51
A4L4 = 52
V8U8 = 60
L6V5U5 = 61
X8L8V8U8 = 62
Q8W8V8U8 = 63
V16U16 = 64
A2W10V10U10 = 67
D16_LOCKABLE = 70
D32 = 71
D15S1 = 73
D24S8 = 75
D24X8 = 77
D24X4S4 = 79
D16 = 80
D32F_LOCKABLE = 82
D24FS8 = 83
D32_LOCKABLE = 84
S8_LOCKABLE = 85
L16 = 81
VERTEXDATA = 100
INDEX16 = 101
INDEX32 = 102
Q16W16V16U16 = 110
R16F = 111
G16R16F = 112
A16B16G16R16F = 113
R32F = 114
G32R32F = 115
A32B32G32R32F = 116
CxV8U8 = 117
A1 = 118
A2B10G10R10_XR_BIAS = 119
BINARYBUFFER = 199
UYVY = i32(b"UYVY")
R8G8_B8G8 = i32(b"RGBG")
YUY2 = i32(b"YUY2")
G8R8_G8B8 = i32(b"GRGB")
DXT1 = i32(b"DXT1")
DXT2 = i32(b"DXT2")
DXT3 = i32(b"DXT3")
DXT4 = i32(b"DXT4")
DXT5 = i32(b"DXT5")
DX10 = i32(b"DX10")
BC4S = i32(b"BC4S")
BC4U = i32(b"BC4U")
BC5S = i32(b"BC5S")
BC5U = i32(b"BC5U")
ATI1 = i32(b"ATI1")
ATI2 = i32(b"ATI2")
MULTI2_ARGB8 = i32(b"MET1")
# Backward compatibility layer
module = sys.modules[__name__]
for item in DDSD:
assert item.name is not None
setattr(module, f"DDSD_{item.name}", item.value)
for item1 in DDSCAPS:
assert item1.name is not None
setattr(module, f"DDSCAPS_{item1.name}", item1.value)
for item2 in DDSCAPS2:
assert item2.name is not None
setattr(module, f"DDSCAPS2_{item2.name}", item2.value)
for item3 in DDPF:
assert item3.name is not None
setattr(module, f"DDPF_{item3.name}", item3.value)
DDS_FOURCC = DDPF.FOURCC
DDS_RGB = DDPF.RGB
DDS_RGBA = DDPF.RGB | DDPF.ALPHAPIXELS
DDS_LUMINANCE = DDPF.LUMINANCE
DDS_LUMINANCEA = DDPF.LUMINANCE | DDPF.ALPHAPIXELS
DDS_ALPHA = DDPF.ALPHA
DDS_PAL8 = DDPF.PALETTEINDEXED8
DDS_HEADER_FLAGS_TEXTURE = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT
DDS_HEADER_FLAGS_MIPMAP = DDSD.MIPMAPCOUNT
DDS_HEADER_FLAGS_VOLUME = DDSD.DEPTH
DDS_HEADER_FLAGS_PITCH = DDSD.PITCH
DDS_HEADER_FLAGS_LINEARSIZE = DDSD.LINEARSIZE
DDS_HEIGHT = DDSD.HEIGHT
DDS_WIDTH = DDSD.WIDTH
DDS_SURFACE_FLAGS_TEXTURE = DDSCAPS.TEXTURE
DDS_SURFACE_FLAGS_MIPMAP = DDSCAPS.COMPLEX | DDSCAPS.MIPMAP
DDS_SURFACE_FLAGS_CUBEMAP = DDSCAPS.COMPLEX
DDS_CUBEMAP_POSITIVEX = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEX
DDS_CUBEMAP_NEGATIVEX = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEX
DDS_CUBEMAP_POSITIVEY = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEY
DDS_CUBEMAP_NEGATIVEY = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEY
DDS_CUBEMAP_POSITIVEZ = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEZ
DDS_CUBEMAP_NEGATIVEZ = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEZ
DXT1_FOURCC = D3DFMT.DXT1
DXT3_FOURCC = D3DFMT.DXT3
DXT5_FOURCC = D3DFMT.DXT5
DXGI_FORMAT_R8G8B8A8_TYPELESS = DXGI_FORMAT.R8G8B8A8_TYPELESS
DXGI_FORMAT_R8G8B8A8_UNORM = DXGI_FORMAT.R8G8B8A8_UNORM
DXGI_FORMAT_R8G8B8A8_UNORM_SRGB = DXGI_FORMAT.R8G8B8A8_UNORM_SRGB
DXGI_FORMAT_BC5_TYPELESS = DXGI_FORMAT.BC5_TYPELESS
DXGI_FORMAT_BC5_UNORM = DXGI_FORMAT.BC5_UNORM
DXGI_FORMAT_BC5_SNORM = DXGI_FORMAT.BC5_SNORM
DXGI_FORMAT_BC6H_UF16 = DXGI_FORMAT.BC6H_UF16
DXGI_FORMAT_BC6H_SF16 = DXGI_FORMAT.BC6H_SF16
DXGI_FORMAT_BC7_TYPELESS = DXGI_FORMAT.BC7_TYPELESS
DXGI_FORMAT_BC7_UNORM = DXGI_FORMAT.BC7_UNORM
DXGI_FORMAT_BC7_UNORM_SRGB = DXGI_FORMAT.BC7_UNORM_SRGB
class DdsImageFile(ImageFile.ImageFile):
format = "DDS"
format_description = "DirectDraw Surface"
def _open(self) -> None:
assert self.fp is not None
if not _accept(self.fp.read(4)):
msg = "not a DDS file"
raise SyntaxError(msg)
(header_size,) = struct.unpack("<I", self.fp.read(4))
if header_size != 124:
msg = f"Unsupported header size {repr(header_size)}"
raise OSError(msg)
header = self.fp.read(header_size - 4)
if len(header) != 120:
msg = f"Incomplete header: {len(header)} bytes"
raise OSError(msg)
flags, height, width = struct.unpack("<3I", header[:12])
self._size = (width, height)
extents = (0, 0) + self.size
pitch, depth, mipmaps = struct.unpack("<3I", header[12:24])
struct.unpack("<11I", header[24:68]) # reserved
# pixel format
pfsize, pfflags, fourcc, bitcount = struct.unpack("<4I", header[68:84])
n = 0
rawmode = None
if pfflags & DDPF.RGB:
# Texture contains uncompressed RGB data
if pfflags & DDPF.ALPHAPIXELS:
self._mode = "RGBA"
mask_count = 4
else:
self._mode = "RGB"
mask_count = 3
masks = struct.unpack(f"<{mask_count}I", header[84 : 84 + mask_count * 4])
self.tile = [ImageFile._Tile("dds_rgb", extents, 0, (bitcount, masks))]
return
elif pfflags & DDPF.LUMINANCE:
if bitcount == 8:
self._mode = "L"
elif bitcount == 16 and pfflags & DDPF.ALPHAPIXELS:
self._mode = "LA"
else:
msg = f"Unsupported bitcount {bitcount} for {pfflags}"
raise OSError(msg)
elif pfflags & DDPF.PALETTEINDEXED8:
self._mode = "P"
self.palette = ImagePalette.raw("RGBA", self.fp.read(1024))
self.palette.mode = "RGBA"
elif pfflags & DDPF.FOURCC:
offset = header_size + 4
if fourcc == D3DFMT.DXT1:
self._mode = "RGBA"
self.pixel_format = "DXT1"
n = 1
elif fourcc == D3DFMT.DXT3:
self._mode = "RGBA"
self.pixel_format = "DXT3"
n = 2
elif fourcc == D3DFMT.DXT5:
self._mode = "RGBA"
self.pixel_format = "DXT5"
n = 3
elif fourcc in (D3DFMT.BC4U, D3DFMT.ATI1):
self._mode = "L"
self.pixel_format = "BC4"
n = 4
elif fourcc == D3DFMT.BC5S:
self._mode = "RGB"
self.pixel_format = "BC5S"
n = 5
elif fourcc in (D3DFMT.BC5U, D3DFMT.ATI2):
self._mode = "RGB"
self.pixel_format = "BC5"
n = 5
elif fourcc == D3DFMT.DX10:
offset += 20
# ignoring flags which pertain to volume textures and cubemaps
(dxgi_format,) = struct.unpack("<I", self.fp.read(4))
self.fp.read(16)
if dxgi_format in (
DXGI_FORMAT.BC1_UNORM,
DXGI_FORMAT.BC1_TYPELESS,
):
self._mode = "RGBA"
self.pixel_format = "BC1"
n = 1
elif dxgi_format in (DXGI_FORMAT.BC2_TYPELESS, DXGI_FORMAT.BC2_UNORM):
self._mode = "RGBA"
self.pixel_format = "BC2"
n = 2
elif dxgi_format in (DXGI_FORMAT.BC3_TYPELESS, DXGI_FORMAT.BC3_UNORM):
self._mode = "RGBA"
self.pixel_format = "BC3"
n = 3
elif dxgi_format in (DXGI_FORMAT.BC4_TYPELESS, DXGI_FORMAT.BC4_UNORM):
self._mode = "L"
self.pixel_format = "BC4"
n = 4
elif dxgi_format in (DXGI_FORMAT.BC5_TYPELESS, DXGI_FORMAT.BC5_UNORM):
self._mode = "RGB"
self.pixel_format = "BC5"
n = 5
elif dxgi_format == DXGI_FORMAT.BC5_SNORM:
self._mode = "RGB"
self.pixel_format = "BC5S"
n = 5
elif dxgi_format == DXGI_FORMAT.BC6H_UF16:
self._mode = "RGB"
self.pixel_format = "BC6H"
n = 6
elif dxgi_format == DXGI_FORMAT.BC6H_SF16:
self._mode = "RGB"
self.pixel_format = "BC6HS"
n = 6
elif dxgi_format in (
DXGI_FORMAT.BC7_TYPELESS,
DXGI_FORMAT.BC7_UNORM,
DXGI_FORMAT.BC7_UNORM_SRGB,
):
self._mode = "RGBA"
self.pixel_format = "BC7"
n = 7
if dxgi_format == DXGI_FORMAT.BC7_UNORM_SRGB:
self.info["gamma"] = 1 / 2.2
elif dxgi_format in (
DXGI_FORMAT.R8G8B8A8_TYPELESS,
DXGI_FORMAT.R8G8B8A8_UNORM,
DXGI_FORMAT.R8G8B8A8_UNORM_SRGB,
):
self._mode = "RGBA"
if dxgi_format == DXGI_FORMAT.R8G8B8A8_UNORM_SRGB:
self.info["gamma"] = 1 / 2.2
else:
msg = f"Unimplemented DXGI format {dxgi_format}"
raise NotImplementedError(msg)
else:
msg = f"Unimplemented pixel format {repr(fourcc)}"
raise NotImplementedError(msg)
else:
msg = f"Unknown pixel format flags {pfflags}"
raise NotImplementedError(msg)
if n:
self.tile = [
ImageFile._Tile("bcn", extents, offset, (n, self.pixel_format))
]
else:
self.tile = [ImageFile._Tile("raw", extents, 0, rawmode or self.mode)]
def load_seek(self, pos: int) -> None:
pass
class DdsRgbDecoder(ImageFile.PyDecoder):
_pulls_fd = True
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
assert self.fd is not None
bitcount, masks = self.args
# Some masks will be padded with zeros, e.g. R 0b11 G 0b1100
# Calculate how many zeros each mask is padded with
mask_offsets = []
# And the maximum value of each channel without the padding
mask_totals = []
for mask in masks:
offset = 0
if mask != 0:
while mask >> (offset + 1) << (offset + 1) == mask:
offset += 1
mask_offsets.append(offset)
mask_totals.append(mask >> offset)
data = bytearray()
bytecount = bitcount // 8
dest_length = self.state.xsize * self.state.ysize * len(masks)
while len(data) < dest_length:
value = int.from_bytes(self.fd.read(bytecount), "little")
for i, mask in enumerate(masks):
masked_value = value & mask
# Remove the zero padding, and scale it to 8 bits
data += o8(
int(((masked_value >> mask_offsets[i]) / mask_totals[i]) * 255)
if mask_totals[i]
else 0
)
self.set_as_raw(data)
return -1, 0
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if im.mode not in ("RGB", "RGBA", "L", "LA"):
msg = f"cannot write mode {im.mode} as DDS"
raise OSError(msg)
flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT
bitcount = len(im.getbands()) * 8
pixel_format = im.encoderinfo.get("pixel_format")
args: tuple[int] | str
if pixel_format:
codec_name = "bcn"
flags |= DDSD.LINEARSIZE
pitch = (im.width + 3) * 4
rgba_mask = [0, 0, 0, 0]
pixel_flags = DDPF.FOURCC
if pixel_format == "DXT1":
fourcc = D3DFMT.DXT1
args = (1,)
elif pixel_format == "DXT3":
fourcc = D3DFMT.DXT3
args = (2,)
elif pixel_format == "DXT5":
fourcc = D3DFMT.DXT5
args = (3,)
else:
fourcc = D3DFMT.DX10
if pixel_format == "BC2":
args = (2,)
dxgi_format = DXGI_FORMAT.BC2_TYPELESS
elif pixel_format == "BC3":
args = (3,)
dxgi_format = DXGI_FORMAT.BC3_TYPELESS
elif pixel_format == "BC5":
args = (5,)
dxgi_format = DXGI_FORMAT.BC5_TYPELESS
if im.mode != "RGB":
msg = "only RGB mode can be written as BC5"
raise OSError(msg)
else:
msg = f"cannot write pixel format {pixel_format}"
raise OSError(msg)
else:
codec_name = "raw"
flags |= DDSD.PITCH
pitch = (im.width * bitcount + 7) // 8
alpha = im.mode[-1] == "A"
if im.mode[0] == "L":
pixel_flags = DDPF.LUMINANCE
args = im.mode
if alpha:
rgba_mask = [0x000000FF, 0x000000FF, 0x000000FF]
else:
rgba_mask = [0xFF000000, 0xFF000000, 0xFF000000]
else:
pixel_flags = DDPF.RGB
args = im.mode[::-1]
rgba_mask = [0x00FF0000, 0x0000FF00, 0x000000FF]
if alpha:
r, g, b, a = im.split()
im = Image.merge("RGBA", (a, r, g, b))
if alpha:
pixel_flags |= DDPF.ALPHAPIXELS
rgba_mask.append(0xFF000000 if alpha else 0)
fourcc = D3DFMT.UNKNOWN
fp.write(
o32(DDS_MAGIC)
+ struct.pack(
"<7I",
124, # header size
flags, # flags
im.height,
im.width,
pitch,
0, # depth
0, # mipmaps
)
+ struct.pack("11I", *((0,) * 11)) # reserved
# pfsize, pfflags, fourcc, bitcount
+ struct.pack("<4I", 32, pixel_flags, fourcc, bitcount)
+ struct.pack("<4I", *rgba_mask) # dwRGBABitMask
+ struct.pack("<5I", DDSCAPS.TEXTURE, 0, 0, 0, 0)
)
if fourcc == D3DFMT.DX10:
fp.write(
# dxgi_format, 2D resource, misc, array size, straight alpha
struct.pack("<5I", dxgi_format, 3, 0, 0, 1)
)
ImageFile._save(im, fp, [ImageFile._Tile(codec_name, (0, 0) + im.size, 0, args)])
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"DDS ")
Image.register_open(DdsImageFile.format, DdsImageFile, _accept)
Image.register_decoder("dds_rgb", DdsRgbDecoder)
Image.register_save(DdsImageFile.format, _save)
Image.register_extension(DdsImageFile.format, ".dds")
@@ -0,0 +1,481 @@
#
# The Python Imaging Library.
# $Id$
#
# EPS file handling
#
# History:
# 1995-09-01 fl Created (0.1)
# 1996-05-18 fl Don't choke on "atend" fields, Ghostscript interface (0.2)
# 1996-08-22 fl Don't choke on floating point BoundingBox values
# 1996-08-23 fl Handle files from Macintosh (0.3)
# 2001-02-17 fl Use 're' instead of 'regex' (Python 2.1) (0.4)
# 2003-09-07 fl Check gs.close status (from Federico Di Gregorio) (0.5)
# 2014-05-07 e Handling of EPS with binary preview and fixed resolution
# resizing
#
# Copyright (c) 1997-2003 by Secret Labs AB.
# Copyright (c) 1995-2003 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import io
import os
import re
import subprocess
import sys
import tempfile
from typing import IO
from . import Image, ImageFile
from ._binary import i32le as i32
# --------------------------------------------------------------------
split = re.compile(r"^%%([^:]*):[ \t]*(.*)[ \t]*$")
field = re.compile(r"^%[%!\w]([^:]*)[ \t]*$")
gs_binary: str | bool | None = None
gs_windows_binary = None
def has_ghostscript() -> bool:
global gs_binary, gs_windows_binary
if gs_binary is None:
if sys.platform.startswith("win"):
if gs_windows_binary is None:
import shutil
for binary in ("gswin32c", "gswin64c", "gs"):
if shutil.which(binary) is not None:
gs_windows_binary = binary
break
else:
gs_windows_binary = False
gs_binary = gs_windows_binary
else:
try:
subprocess.check_call(["gs", "--version"], stdout=subprocess.DEVNULL)
gs_binary = "gs"
except OSError:
gs_binary = False
return gs_binary is not False
def Ghostscript(
tile: list[ImageFile._Tile],
size: tuple[int, int],
fp: IO[bytes],
scale: int = 1,
transparency: bool = False,
) -> Image.core.ImagingCore:
"""Render an image using Ghostscript"""
global gs_binary
if not has_ghostscript():
msg = "Unable to locate Ghostscript on paths"
raise OSError(msg)
assert isinstance(gs_binary, str)
# Unpack decoder tile
args = tile[0].args
assert isinstance(args, tuple)
length, bbox = args
# Hack to support hi-res rendering
scale = int(scale) or 1
width = size[0] * scale
height = size[1] * scale
# resolution is dependent on bbox and size
res_x = 72.0 * width / (bbox[2] - bbox[0])
res_y = 72.0 * height / (bbox[3] - bbox[1])
out_fd, outfile = tempfile.mkstemp()
os.close(out_fd)
infile_temp = None
if hasattr(fp, "name") and os.path.exists(fp.name):
infile = fp.name
else:
in_fd, infile_temp = tempfile.mkstemp()
os.close(in_fd)
infile = infile_temp
# Ignore length and offset!
# Ghostscript can read it
# Copy whole file to read in Ghostscript
with open(infile_temp, "wb") as f:
# fetch length of fp
fp.seek(0, io.SEEK_END)
fsize = fp.tell()
# ensure start position
# go back
fp.seek(0)
lengthfile = fsize
while lengthfile > 0:
s = fp.read(min(lengthfile, 100 * 1024))
if not s:
break
lengthfile -= len(s)
f.write(s)
if transparency:
# "RGBA"
device = "pngalpha"
else:
# "pnmraw" automatically chooses between
# PBM ("1"), PGM ("L"), and PPM ("RGB").
device = "pnmraw"
# Build Ghostscript command
command = [
gs_binary,
"-q", # quiet mode
f"-g{width:d}x{height:d}", # set output geometry (pixels)
f"-r{res_x:f}x{res_y:f}", # set input DPI (dots per inch)
"-dBATCH", # exit after processing
"-dNOPAUSE", # don't pause between pages
"-dSAFER", # safe mode
f"-sDEVICE={device}",
f"-sOutputFile={outfile}", # output file
# adjust for image origin
"-c",
f"{-bbox[0]} {-bbox[1]} translate",
"-f",
infile, # input file
# showpage (see https://bugs.ghostscript.com/show_bug.cgi?id=698272)
"-c",
"showpage",
]
# push data through Ghostscript
try:
startupinfo = None
if sys.platform.startswith("win"):
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
subprocess.check_call(command, startupinfo=startupinfo)
with Image.open(outfile) as out_im:
out_im.load()
return out_im.im.copy()
finally:
try:
os.unlink(outfile)
if infile_temp:
os.unlink(infile_temp)
except OSError:
pass
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"%!PS") or (
len(prefix) >= 4 and i32(prefix) == 0xC6D3D0C5
)
##
# Image plugin for Encapsulated PostScript. This plugin supports only
# a few variants of this format.
class EpsImageFile(ImageFile.ImageFile):
"""EPS File Parser for the Python Imaging Library"""
format = "EPS"
format_description = "Encapsulated Postscript"
mode_map = {1: "L", 2: "LAB", 3: "RGB", 4: "CMYK"}
def _open(self) -> None:
assert self.fp is not None
length, offset = self._find_offset(self.fp)
# go to offset - start of "%!PS"
self.fp.seek(offset)
self._mode = "RGB"
# When reading header comments, the first comment is used.
# When reading trailer comments, the last comment is used.
bounding_box: list[int] | None = None
imagedata_size: tuple[int, int] | None = None
byte_arr = bytearray(255)
bytes_mv = memoryview(byte_arr)
bytes_read = 0
reading_header_comments = True
reading_trailer_comments = False
trailer_reached = False
def check_required_header_comments() -> None:
"""
The EPS specification requires that some headers exist.
This should be checked when the header comments formally end,
when image data starts, or when the file ends, whichever comes first.
"""
if "PS-Adobe" not in self.info:
msg = 'EPS header missing "%!PS-Adobe" comment'
raise SyntaxError(msg)
if "BoundingBox" not in self.info:
msg = 'EPS header missing "%%BoundingBox" comment'
raise SyntaxError(msg)
def read_comment(s: str) -> bool:
nonlocal bounding_box, reading_trailer_comments
try:
m = split.match(s)
except re.error as e:
msg = "not an EPS file"
raise SyntaxError(msg) from e
if not m:
return False
k, v = m.group(1, 2)
self.info[k] = v
if k == "BoundingBox":
if v == "(atend)":
reading_trailer_comments = True
elif not bounding_box or (trailer_reached and reading_trailer_comments):
try:
# Note: The DSC spec says that BoundingBox
# fields should be integers, but some drivers
# put floating point values there anyway.
bounding_box = [int(float(i)) for i in v.split()]
except Exception:
pass
return True
while True:
byte = self.fp.read(1)
if byte == b"":
# if we didn't read a byte we must be at the end of the file
if bytes_read == 0:
if reading_header_comments:
check_required_header_comments()
break
elif byte in b"\r\n":
# if we read a line ending character, ignore it and parse what
# we have already read. if we haven't read any other characters,
# continue reading
if bytes_read == 0:
continue
else:
# ASCII/hexadecimal lines in an EPS file must not exceed
# 255 characters, not including line ending characters
if bytes_read >= 255:
# only enforce this for lines starting with a "%",
# otherwise assume it's binary data
if byte_arr[0] == ord("%"):
msg = "not an EPS file"
raise SyntaxError(msg)
else:
if reading_header_comments:
check_required_header_comments()
reading_header_comments = False
# reset bytes_read so we can keep reading
# data until the end of the line
bytes_read = 0
byte_arr[bytes_read] = byte[0]
bytes_read += 1
continue
if reading_header_comments:
# Load EPS header
# if this line doesn't start with a "%",
# or does start with "%%EndComments",
# then we've reached the end of the header/comments
if byte_arr[0] != ord("%") or bytes_mv[:13] == b"%%EndComments":
check_required_header_comments()
reading_header_comments = False
continue
s = str(bytes_mv[:bytes_read], "latin-1")
if not read_comment(s):
m = field.match(s)
if m:
k = m.group(1)
if k.startswith("PS-Adobe"):
self.info["PS-Adobe"] = k[9:]
else:
self.info[k] = ""
elif s[0] == "%":
# handle non-DSC PostScript comments that some
# tools mistakenly put in the Comments section
pass
else:
msg = "bad EPS header"
raise OSError(msg)
elif bytes_mv[:11] == b"%ImageData:":
# Check for an "ImageData" descriptor
# https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577413_pgfId-1035096
# If we've already read an "ImageData" descriptor,
# don't read another one.
if imagedata_size:
bytes_read = 0
continue
# Values:
# columns
# rows
# bit depth (1 or 8)
# mode (1: L, 2: LAB, 3: RGB, 4: CMYK)
# number of padding channels
# block size (number of bytes per row per channel)
# binary/ascii (1: binary, 2: ascii)
# data start identifier (the image data follows after a single line
# consisting only of this quoted value)
image_data_values = byte_arr[11:bytes_read].split(None, 7)
columns, rows, bit_depth, mode_id = (
int(value) for value in image_data_values[:4]
)
if bit_depth == 1:
self._mode = "1"
elif bit_depth == 8:
try:
self._mode = self.mode_map[mode_id]
except ValueError:
break
else:
break
# Parse the columns and rows after checking the bit depth and mode
# in case the bit depth and/or mode are invalid.
imagedata_size = columns, rows
elif bytes_mv[:5] == b"%%EOF":
break
elif trailer_reached and reading_trailer_comments:
# Load EPS trailer
s = str(bytes_mv[:bytes_read], "latin-1")
read_comment(s)
elif bytes_mv[:9] == b"%%Trailer":
trailer_reached = True
elif bytes_mv[:14] == b"%%BeginBinary:":
bytecount = int(byte_arr[14:bytes_read])
self.fp.seek(bytecount, os.SEEK_CUR)
bytes_read = 0
# A "BoundingBox" is always required,
# even if an "ImageData" descriptor size exists.
if not bounding_box:
msg = "cannot determine EPS bounding box"
raise OSError(msg)
# An "ImageData" size takes precedence over the "BoundingBox".
self._size = imagedata_size or (
bounding_box[2] - bounding_box[0],
bounding_box[3] - bounding_box[1],
)
self.tile = [
ImageFile._Tile("eps", (0, 0) + self.size, offset, (length, bounding_box))
]
def _find_offset(self, fp: IO[bytes]) -> tuple[int, int]:
s = fp.read(4)
if s == b"%!PS":
# for HEAD without binary preview
fp.seek(0, io.SEEK_END)
length = fp.tell()
offset = 0
elif i32(s) == 0xC6D3D0C5:
# FIX for: Some EPS file not handled correctly / issue #302
# EPS can contain binary data
# or start directly with latin coding
# more info see:
# https://web.archive.org/web/20160528181353/http://partners.adobe.com/public/developer/en/ps/5002.EPSF_Spec.pdf
s = fp.read(8)
offset = i32(s)
length = i32(s, 4)
else:
msg = "not an EPS file"
raise SyntaxError(msg)
return length, offset
def load(
self, scale: int = 1, transparency: bool = False
) -> Image.core.PixelAccess | None:
# Load EPS via Ghostscript
if self.tile:
assert self.fp is not None
self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency)
self._mode = self.im.mode
self._size = self.im.size
self.tile = []
return Image.Image.load(self)
def load_seek(self, pos: int) -> None:
# we can't incrementally load, so force ImageFile.parser to
# use our custom load method by defining this method.
pass
# --------------------------------------------------------------------
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes, eps: int = 1) -> None:
"""EPS Writer for the Python Imaging Library."""
# make sure image data is available
im.load()
# determine PostScript image mode
if im.mode == "L":
operator = (8, 1, b"image")
elif im.mode == "RGB":
operator = (8, 3, b"false 3 colorimage")
elif im.mode == "CMYK":
operator = (8, 4, b"false 4 colorimage")
else:
msg = "image mode is not supported"
raise ValueError(msg)
if eps:
# write EPS header
fp.write(b"%!PS-Adobe-3.0 EPSF-3.0\n")
fp.write(b"%%Creator: PIL 0.1 EpsEncode\n")
# fp.write("%%CreationDate: %s"...)
fp.write(b"%%%%BoundingBox: 0 0 %d %d\n" % im.size)
fp.write(b"%%Pages: 1\n")
fp.write(b"%%EndComments\n")
fp.write(b"%%Page: 1 1\n")
fp.write(b"%%ImageData: %d %d " % im.size)
fp.write(b'%d %d 0 1 1 "%s"\n' % operator)
# image header
fp.write(b"gsave\n")
fp.write(b"10 dict begin\n")
fp.write(b"/buf %d string def\n" % (im.size[0] * operator[1]))
fp.write(b"%d %d scale\n" % im.size)
fp.write(b"%d %d 8\n" % im.size) # <= bits
fp.write(b"[%d 0 0 -%d 0 %d]\n" % (im.size[0], im.size[1], im.size[1]))
fp.write(b"{ currentfile buf readhexstring pop } bind\n")
fp.write(operator[2] + b"\n")
if hasattr(fp, "flush"):
fp.flush()
ImageFile._save(im, fp, [ImageFile._Tile("eps", (0, 0) + im.size)])
fp.write(b"\n%%%%EndBinary\n")
fp.write(b"grestore end\n")
if hasattr(fp, "flush"):
fp.flush()
# --------------------------------------------------------------------
Image.register_open(EpsImageFile.format, EpsImageFile, _accept)
Image.register_save(EpsImageFile.format, _save)
Image.register_extensions(EpsImageFile.format, [".ps", ".eps"])
Image.register_mime(EpsImageFile.format, "application/postscript")
@@ -0,0 +1,384 @@
#
# The Python Imaging Library.
# $Id$
#
# EXIF tags
#
# Copyright (c) 2003 by Secret Labs AB
#
# See the README file for information on usage and redistribution.
#
"""
This module provides constants and clear-text names for various
well-known EXIF tags.
"""
from __future__ import annotations
from enum import IntEnum
class Base(IntEnum):
# possibly incomplete
InteropIndex = 0x0001
ProcessingSoftware = 0x000B
NewSubfileType = 0x00FE
SubfileType = 0x00FF
ImageWidth = 0x0100
ImageLength = 0x0101
BitsPerSample = 0x0102
Compression = 0x0103
PhotometricInterpretation = 0x0106
Thresholding = 0x0107
CellWidth = 0x0108
CellLength = 0x0109
FillOrder = 0x010A
DocumentName = 0x010D
ImageDescription = 0x010E
Make = 0x010F
Model = 0x0110
StripOffsets = 0x0111
Orientation = 0x0112
SamplesPerPixel = 0x0115
RowsPerStrip = 0x0116
StripByteCounts = 0x0117
MinSampleValue = 0x0118
MaxSampleValue = 0x0119
XResolution = 0x011A
YResolution = 0x011B
PlanarConfiguration = 0x011C
PageName = 0x011D
FreeOffsets = 0x0120
FreeByteCounts = 0x0121
GrayResponseUnit = 0x0122
GrayResponseCurve = 0x0123
T4Options = 0x0124
T6Options = 0x0125
ResolutionUnit = 0x0128
PageNumber = 0x0129
TransferFunction = 0x012D
Software = 0x0131
DateTime = 0x0132
Artist = 0x013B
HostComputer = 0x013C
Predictor = 0x013D
WhitePoint = 0x013E
PrimaryChromaticities = 0x013F
ColorMap = 0x0140
HalftoneHints = 0x0141
TileWidth = 0x0142
TileLength = 0x0143
TileOffsets = 0x0144
TileByteCounts = 0x0145
SubIFDs = 0x014A
InkSet = 0x014C
InkNames = 0x014D
NumberOfInks = 0x014E
DotRange = 0x0150
TargetPrinter = 0x0151
ExtraSamples = 0x0152
SampleFormat = 0x0153
SMinSampleValue = 0x0154
SMaxSampleValue = 0x0155
TransferRange = 0x0156
ClipPath = 0x0157
XClipPathUnits = 0x0158
YClipPathUnits = 0x0159
Indexed = 0x015A
JPEGTables = 0x015B
OPIProxy = 0x015F
JPEGProc = 0x0200
JpegIFOffset = 0x0201
JpegIFByteCount = 0x0202
JpegRestartInterval = 0x0203
JpegLosslessPredictors = 0x0205
JpegPointTransforms = 0x0206
JpegQTables = 0x0207
JpegDCTables = 0x0208
JpegACTables = 0x0209
YCbCrCoefficients = 0x0211
YCbCrSubSampling = 0x0212
YCbCrPositioning = 0x0213
ReferenceBlackWhite = 0x0214
XMLPacket = 0x02BC
RelatedImageFileFormat = 0x1000
RelatedImageWidth = 0x1001
RelatedImageLength = 0x1002
Rating = 0x4746
RatingPercent = 0x4749
ImageID = 0x800D
CFARepeatPatternDim = 0x828D
BatteryLevel = 0x828F
Copyright = 0x8298
ExposureTime = 0x829A
FNumber = 0x829D
IPTCNAA = 0x83BB
ImageResources = 0x8649
ExifOffset = 0x8769
InterColorProfile = 0x8773
ExposureProgram = 0x8822
SpectralSensitivity = 0x8824
GPSInfo = 0x8825
ISOSpeedRatings = 0x8827
OECF = 0x8828
Interlace = 0x8829
TimeZoneOffset = 0x882A
SelfTimerMode = 0x882B
SensitivityType = 0x8830
StandardOutputSensitivity = 0x8831
RecommendedExposureIndex = 0x8832
ISOSpeed = 0x8833
ISOSpeedLatitudeyyy = 0x8834
ISOSpeedLatitudezzz = 0x8835
ExifVersion = 0x9000
DateTimeOriginal = 0x9003
DateTimeDigitized = 0x9004
OffsetTime = 0x9010
OffsetTimeOriginal = 0x9011
OffsetTimeDigitized = 0x9012
ComponentsConfiguration = 0x9101
CompressedBitsPerPixel = 0x9102
ShutterSpeedValue = 0x9201
ApertureValue = 0x9202
BrightnessValue = 0x9203
ExposureBiasValue = 0x9204
MaxApertureValue = 0x9205
SubjectDistance = 0x9206
MeteringMode = 0x9207
LightSource = 0x9208
Flash = 0x9209
FocalLength = 0x920A
Noise = 0x920D
ImageNumber = 0x9211
SecurityClassification = 0x9212
ImageHistory = 0x9213
TIFFEPStandardID = 0x9216
MakerNote = 0x927C
UserComment = 0x9286
SubsecTime = 0x9290
SubsecTimeOriginal = 0x9291
SubsecTimeDigitized = 0x9292
AmbientTemperature = 0x9400
Humidity = 0x9401
Pressure = 0x9402
WaterDepth = 0x9403
Acceleration = 0x9404
CameraElevationAngle = 0x9405
XPTitle = 0x9C9B
XPComment = 0x9C9C
XPAuthor = 0x9C9D
XPKeywords = 0x9C9E
XPSubject = 0x9C9F
FlashPixVersion = 0xA000
ColorSpace = 0xA001
ExifImageWidth = 0xA002
ExifImageHeight = 0xA003
RelatedSoundFile = 0xA004
ExifInteroperabilityOffset = 0xA005
FlashEnergy = 0xA20B
SpatialFrequencyResponse = 0xA20C
FocalPlaneXResolution = 0xA20E
FocalPlaneYResolution = 0xA20F
FocalPlaneResolutionUnit = 0xA210
SubjectLocation = 0xA214
ExposureIndex = 0xA215
SensingMethod = 0xA217
FileSource = 0xA300
SceneType = 0xA301
CFAPattern = 0xA302
CustomRendered = 0xA401
ExposureMode = 0xA402
WhiteBalance = 0xA403
DigitalZoomRatio = 0xA404
FocalLengthIn35mmFilm = 0xA405
SceneCaptureType = 0xA406
GainControl = 0xA407
Contrast = 0xA408
Saturation = 0xA409
Sharpness = 0xA40A
DeviceSettingDescription = 0xA40B
SubjectDistanceRange = 0xA40C
ImageUniqueID = 0xA420
CameraOwnerName = 0xA430
BodySerialNumber = 0xA431
LensSpecification = 0xA432
LensMake = 0xA433
LensModel = 0xA434
LensSerialNumber = 0xA435
CompositeImage = 0xA460
CompositeImageCount = 0xA461
CompositeImageExposureTimes = 0xA462
Gamma = 0xA500
PrintImageMatching = 0xC4A5
DNGVersion = 0xC612
DNGBackwardVersion = 0xC613
UniqueCameraModel = 0xC614
LocalizedCameraModel = 0xC615
CFAPlaneColor = 0xC616
CFALayout = 0xC617
LinearizationTable = 0xC618
BlackLevelRepeatDim = 0xC619
BlackLevel = 0xC61A
BlackLevelDeltaH = 0xC61B
BlackLevelDeltaV = 0xC61C
WhiteLevel = 0xC61D
DefaultScale = 0xC61E
DefaultCropOrigin = 0xC61F
DefaultCropSize = 0xC620
ColorMatrix1 = 0xC621
ColorMatrix2 = 0xC622
CameraCalibration1 = 0xC623
CameraCalibration2 = 0xC624
ReductionMatrix1 = 0xC625
ReductionMatrix2 = 0xC626
AnalogBalance = 0xC627
AsShotNeutral = 0xC628
AsShotWhiteXY = 0xC629
BaselineExposure = 0xC62A
BaselineNoise = 0xC62B
BaselineSharpness = 0xC62C
BayerGreenSplit = 0xC62D
LinearResponseLimit = 0xC62E
CameraSerialNumber = 0xC62F
LensInfo = 0xC630
ChromaBlurRadius = 0xC631
AntiAliasStrength = 0xC632
ShadowScale = 0xC633
DNGPrivateData = 0xC634
MakerNoteSafety = 0xC635
CalibrationIlluminant1 = 0xC65A
CalibrationIlluminant2 = 0xC65B
BestQualityScale = 0xC65C
RawDataUniqueID = 0xC65D
OriginalRawFileName = 0xC68B
OriginalRawFileData = 0xC68C
ActiveArea = 0xC68D
MaskedAreas = 0xC68E
AsShotICCProfile = 0xC68F
AsShotPreProfileMatrix = 0xC690
CurrentICCProfile = 0xC691
CurrentPreProfileMatrix = 0xC692
ColorimetricReference = 0xC6BF
CameraCalibrationSignature = 0xC6F3
ProfileCalibrationSignature = 0xC6F4
AsShotProfileName = 0xC6F6
NoiseReductionApplied = 0xC6F7
ProfileName = 0xC6F8
ProfileHueSatMapDims = 0xC6F9
ProfileHueSatMapData1 = 0xC6FA
ProfileHueSatMapData2 = 0xC6FB
ProfileToneCurve = 0xC6FC
ProfileEmbedPolicy = 0xC6FD
ProfileCopyright = 0xC6FE
ForwardMatrix1 = 0xC714
ForwardMatrix2 = 0xC715
PreviewApplicationName = 0xC716
PreviewApplicationVersion = 0xC717
PreviewSettingsName = 0xC718
PreviewSettingsDigest = 0xC719
PreviewColorSpace = 0xC71A
PreviewDateTime = 0xC71B
RawImageDigest = 0xC71C
OriginalRawFileDigest = 0xC71D
SubTileBlockSize = 0xC71E
RowInterleaveFactor = 0xC71F
ProfileLookTableDims = 0xC725
ProfileLookTableData = 0xC726
OpcodeList1 = 0xC740
OpcodeList2 = 0xC741
OpcodeList3 = 0xC74E
NoiseProfile = 0xC761
FrameRate = 0xC764
"""Maps EXIF tags to tag names."""
TAGS = {
**{i.value: i.name for i in Base},
0x920C: "SpatialFrequencyResponse",
0x9214: "SubjectLocation",
0x9215: "ExposureIndex",
0x828E: "CFAPattern",
0x920B: "FlashEnergy",
0x9216: "TIFF/EPStandardID",
}
class GPS(IntEnum):
GPSVersionID = 0x00
GPSLatitudeRef = 0x01
GPSLatitude = 0x02
GPSLongitudeRef = 0x03
GPSLongitude = 0x04
GPSAltitudeRef = 0x05
GPSAltitude = 0x06
GPSTimeStamp = 0x07
GPSSatellites = 0x08
GPSStatus = 0x09
GPSMeasureMode = 0x0A
GPSDOP = 0x0B
GPSSpeedRef = 0x0C
GPSSpeed = 0x0D
GPSTrackRef = 0x0E
GPSTrack = 0x0F
GPSImgDirectionRef = 0x10
GPSImgDirection = 0x11
GPSMapDatum = 0x12
GPSDestLatitudeRef = 0x13
GPSDestLatitude = 0x14
GPSDestLongitudeRef = 0x15
GPSDestLongitude = 0x16
GPSDestBearingRef = 0x17
GPSDestBearing = 0x18
GPSDestDistanceRef = 0x19
GPSDestDistance = 0x1A
GPSProcessingMethod = 0x1B
GPSAreaInformation = 0x1C
GPSDateStamp = 0x1D
GPSDifferential = 0x1E
GPSHPositioningError = 0x1F
"""Maps EXIF GPS tags to tag names."""
GPSTAGS = {i.value: i.name for i in GPS}
class Interop(IntEnum):
InteropIndex = 0x0001
InteropVersion = 0x0002
RelatedImageFileFormat = 0x1000
RelatedImageWidth = 0x1001
RelatedImageHeight = 0x1002
class IFD(IntEnum):
Exif = 0x8769
GPSInfo = 0x8825
MakerNote = 0x927C
Makernote = 0x927C # Deprecated
Interop = 0xA005
IFD1 = -1
class LightSource(IntEnum):
Unknown = 0x00
Daylight = 0x01
Fluorescent = 0x02
Tungsten = 0x03
Flash = 0x04
Fine = 0x09
Cloudy = 0x0A
Shade = 0x0B
DaylightFluorescent = 0x0C
DayWhiteFluorescent = 0x0D
CoolWhiteFluorescent = 0x0E
WhiteFluorescent = 0x0F
StandardLightA = 0x11
StandardLightB = 0x12
StandardLightC = 0x13
D55 = 0x14
D65 = 0x15
D75 = 0x16
D50 = 0x17
ISO = 0x18
Other = 0xFF
@@ -0,0 +1,153 @@
#
# The Python Imaging Library
# $Id$
#
# FITS file handling
#
# Copyright (c) 1998-2003 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import gzip
import math
from . import Image, ImageFile
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"SIMPLE")
class FitsImageFile(ImageFile.ImageFile):
format = "FITS"
format_description = "FITS"
def _open(self) -> None:
assert self.fp is not None
headers: dict[bytes, bytes] = {}
header_in_progress = False
decoder_name = ""
while True:
header = self.fp.read(80)
if not header:
msg = "Truncated FITS file"
raise OSError(msg)
keyword = header[:8].strip()
if keyword in (b"SIMPLE", b"XTENSION"):
header_in_progress = True
elif headers and not header_in_progress:
# This is now a data unit
break
elif keyword == b"END":
# Seek to the end of the header unit
self.fp.seek(math.ceil(self.fp.tell() / 2880) * 2880)
if not decoder_name:
decoder_name, offset, args = self._parse_headers(headers)
header_in_progress = False
continue
if decoder_name:
# Keep going to read past the headers
continue
value = header[8:].split(b"/")[0].strip()
if value.startswith(b"="):
value = value[1:].strip()
if not headers and (not _accept(keyword) or value != b"T"):
msg = "Not a FITS file"
raise SyntaxError(msg)
headers[keyword] = value
if not decoder_name:
msg = "No image data"
raise ValueError(msg)
offset += self.fp.tell() - 80
self.tile = [ImageFile._Tile(decoder_name, (0, 0) + self.size, offset, args)]
def _get_size(
self, headers: dict[bytes, bytes], prefix: bytes
) -> tuple[int, int] | None:
naxis = int(headers[prefix + b"NAXIS"])
if naxis == 0:
return None
if naxis == 1:
return 1, int(headers[prefix + b"NAXIS1"])
else:
return int(headers[prefix + b"NAXIS1"]), int(headers[prefix + b"NAXIS2"])
def _parse_headers(
self, headers: dict[bytes, bytes]
) -> tuple[str, int, tuple[str | int, ...]]:
prefix = b""
decoder_name = "raw"
offset = 0
if (
headers.get(b"XTENSION") == b"'BINTABLE'"
and headers.get(b"ZIMAGE") == b"T"
and headers[b"ZCMPTYPE"] == b"'GZIP_1 '"
):
no_prefix_size = self._get_size(headers, prefix) or (0, 0)
number_of_bits = int(headers[b"BITPIX"])
offset = no_prefix_size[0] * no_prefix_size[1] * (number_of_bits // 8)
prefix = b"Z"
decoder_name = "fits_gzip"
size = self._get_size(headers, prefix)
if not size:
return "", 0, ()
self._size = size
number_of_bits = int(headers[prefix + b"BITPIX"])
if number_of_bits == 8:
self._mode = "L"
elif number_of_bits == 16:
self._mode = "I;16"
elif number_of_bits == 32:
self._mode = "I"
elif number_of_bits in (-32, -64):
self._mode = "F"
args: tuple[str | int, ...]
if decoder_name == "raw":
args = (self.mode, 0, -1)
else:
args = (number_of_bits,)
return decoder_name, offset, args
class FitsGzipDecoder(ImageFile.PyDecoder):
_pulls_fd = True
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
assert self.fd is not None
with gzip.open(self.fd) as fp:
value = fp.read(self.state.xsize * self.state.ysize * 4)
rows = []
offset = 0
number_of_bits = min(self.args[0] // 8, 4)
for y in range(self.state.ysize):
row = bytearray()
for x in range(self.state.xsize):
row += value[offset + (4 - number_of_bits) : offset + 4]
offset += 4
rows.append(row)
self.set_as_raw(bytes([pixel for row in rows[::-1] for pixel in row]))
return -1, 0
# --------------------------------------------------------------------
# Registry
Image.register_open(FitsImageFile.format, FitsImageFile, _accept)
Image.register_decoder("fits_gzip", FitsGzipDecoder)
Image.register_extensions(FitsImageFile.format, [".fit", ".fits"])
@@ -0,0 +1,184 @@
#
# The Python Imaging Library.
# $Id$
#
# FLI/FLC file handling.
#
# History:
# 95-09-01 fl Created
# 97-01-03 fl Fixed parser, setup decoder tile
# 98-07-15 fl Renamed offset attribute to avoid name clash
#
# Copyright (c) Secret Labs AB 1997-98.
# Copyright (c) Fredrik Lundh 1995-97.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import os
from . import Image, ImageFile, ImagePalette
from ._binary import i16le as i16
from ._binary import i32le as i32
from ._binary import o8
from ._util import DeferredError
#
# decoder
def _accept(prefix: bytes) -> bool:
return (
len(prefix) >= 16
and i16(prefix, 4) in [0xAF11, 0xAF12]
and i16(prefix, 14) in [0, 3] # flags
)
##
# Image plugin for the FLI/FLC animation format. Use the <b>seek</b>
# method to load individual frames.
class FliImageFile(ImageFile.ImageFile):
format = "FLI"
format_description = "Autodesk FLI/FLC Animation"
_close_exclusive_fp_after_loading = False
def _open(self) -> None:
# HEAD
assert self.fp is not None
s = self.fp.read(128)
if not (
_accept(s)
and s[20:22] == b"\x00" * 2
and s[42:80] == b"\x00" * 38
and s[88:] == b"\x00" * 40
):
msg = "not an FLI/FLC file"
raise SyntaxError(msg)
# frames
self.n_frames = i16(s, 6)
self.is_animated = self.n_frames > 1
# image characteristics
self._mode = "P"
self._size = i16(s, 8), i16(s, 10)
# animation speed
duration = i32(s, 16)
magic = i16(s, 4)
if magic == 0xAF11:
duration = (duration * 1000) // 70
self.info["duration"] = duration
# look for palette
palette = [(a, a, a) for a in range(256)]
s = self.fp.read(16)
self.__offset = 128
if i16(s, 4) == 0xF100:
# prefix chunk; ignore it
self.fp.seek(self.__offset + i32(s))
s = self.fp.read(16)
if i16(s, 4) == 0xF1FA:
# look for palette chunk
number_of_subchunks = i16(s, 6)
chunk_size: int | None = None
for _ in range(number_of_subchunks):
if chunk_size is not None:
self.fp.seek(chunk_size - 6, os.SEEK_CUR)
s = self.fp.read(6)
chunk_type = i16(s, 4)
if chunk_type in (4, 11):
self._palette(palette, 2 if chunk_type == 11 else 0)
break
chunk_size = i32(s)
if not chunk_size:
break
self.palette = ImagePalette.raw(
"RGB", b"".join(o8(r) + o8(g) + o8(b) for (r, g, b) in palette)
)
# set things up to decode first frame
self.__frame = -1
self._fp = self.fp
self.__rewind = self.fp.tell()
self.seek(0)
def _palette(self, palette: list[tuple[int, int, int]], shift: int) -> None:
# load palette
i = 0
assert self.fp is not None
for e in range(i16(self.fp.read(2))):
s = self.fp.read(2)
i = i + s[0]
n = s[1]
if n == 0:
n = 256
s = self.fp.read(n * 3)
for n in range(0, len(s), 3):
r = s[n] << shift
g = s[n + 1] << shift
b = s[n + 2] << shift
palette[i] = (r, g, b)
i += 1
def seek(self, frame: int) -> None:
if not self._seek_check(frame):
return
if frame < self.__frame:
self._seek(0)
for f in range(self.__frame + 1, frame + 1):
self._seek(f)
def _seek(self, frame: int) -> None:
if isinstance(self._fp, DeferredError):
raise self._fp.ex
if frame == 0:
self.__frame = -1
self._fp.seek(self.__rewind)
self.__offset = 128
else:
# ensure that the previous frame was loaded
self.load()
if frame != self.__frame + 1:
msg = f"cannot seek to frame {frame}"
raise ValueError(msg)
self.__frame = frame
# move to next frame
self.fp = self._fp
self.fp.seek(self.__offset)
s = self.fp.read(4)
if not s:
msg = "missing frame size"
raise EOFError(msg)
framesize = i32(s)
self.decodermaxblock = framesize
self.tile = [ImageFile._Tile("fli", (0, 0) + self.size, self.__offset)]
self.__offset += framesize
def tell(self) -> int:
return self.__frame
#
# registry
Image.register_open(FliImageFile.format, FliImageFile, _accept)
Image.register_extensions(FliImageFile.format, [".fli", ".flc"])
@@ -0,0 +1,159 @@
#
# The Python Imaging Library
# $Id$
#
# base class for raster font file parsers
#
# history:
# 1997-06-05 fl created
# 1997-08-19 fl restrict image width
#
# Copyright (c) 1997-1998 by Secret Labs AB
# Copyright (c) 1997-1998 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import os
from typing import BinaryIO
from . import Image, ImageFont, _binary
WIDTH = 800
def puti16(
fp: BinaryIO, values: tuple[int, int, int, int, int, int, int, int, int, int]
) -> None:
"""Write network order (big-endian) 16-bit sequence"""
for v in values:
if v < 0:
v += 65536
fp.write(_binary.o16be(v))
class FontFile:
"""Base class for raster font file handlers."""
bitmap: Image.Image | None = None
def __init__(self) -> None:
self.info: dict[bytes, bytes | int] = {}
self.glyph: list[
tuple[
tuple[int, int],
tuple[int, int, int, int],
tuple[int, int, int, int],
Image.Image,
]
| None
] = [None] * 256
def __getitem__(self, ix: int) -> (
tuple[
tuple[int, int],
tuple[int, int, int, int],
tuple[int, int, int, int],
Image.Image,
]
| None
):
return self.glyph[ix]
def compile(self) -> None:
"""Create metrics and bitmap"""
if self.bitmap:
return
# create bitmap large enough to hold all data
h = w = maxwidth = 0
lines = 1
for glyph in self.glyph:
if glyph:
d, dst, src, im = glyph
h = max(h, src[3] - src[1])
w = w + (src[2] - src[0])
if w > WIDTH:
lines += 1
w = src[2] - src[0]
maxwidth = max(maxwidth, w)
xsize = maxwidth
ysize = lines * h
if xsize == 0 and ysize == 0:
return
self.ysize = h
# paste glyphs into bitmap
self.bitmap = Image.new("1", (xsize, ysize))
self.metrics: list[
tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]]
| None
] = [None] * 256
x = y = 0
for i in range(256):
glyph = self[i]
if glyph:
d, dst, src, im = glyph
xx = src[2] - src[0]
x0, y0 = x, y
x = x + xx
if x > WIDTH:
x, y = 0, y + h
x0, y0 = x, y
x = xx
s = src[0] + x0, src[1] + y0, src[2] + x0, src[3] + y0
self.bitmap.paste(im.crop(src), s)
self.metrics[i] = d, dst, s
def _encode_metrics(self) -> bytes:
values: list[int] = []
for i in range(256):
m = self.metrics[i]
if m:
values.extend(m[0] + m[1] + m[2])
else:
values.extend((0,) * 10)
data = bytearray()
for v in values:
if v < 0:
v += 65536
data += _binary.o16be(v)
return bytes(data)
def save(self, filename: str) -> None:
"""Save font"""
self.compile()
# font data
if not self.bitmap:
msg = "No bitmap created"
raise ValueError(msg)
self.bitmap.save(os.path.splitext(filename)[0] + ".pbm", "PNG")
# font metrics
with open(os.path.splitext(filename)[0] + ".pil", "wb") as fp:
fp.write(b"PILfont\n")
fp.write(f";;;;;;{self.ysize};\n".encode("ascii")) # HACK!!!
fp.write(b"DATA\n")
fp.write(self._encode_metrics())
def to_imagefont(self) -> ImageFont.ImageFont:
"""Convert to ImageFont"""
self.compile()
# font data
if not self.bitmap:
msg = "No bitmap created"
raise ValueError(msg)
imagefont = ImageFont.ImageFont()
imagefont._load(self.bitmap, self._encode_metrics())
return imagefont
@@ -0,0 +1,258 @@
#
# THIS IS WORK IN PROGRESS
#
# The Python Imaging Library.
# $Id$
#
# FlashPix support for PIL
#
# History:
# 97-01-25 fl Created (reads uncompressed RGB images only)
#
# Copyright (c) Secret Labs AB 1997.
# Copyright (c) Fredrik Lundh 1997.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import olefile
from . import Image, ImageFile
from ._binary import i32le as i32
# we map from colour field tuples to (mode, rawmode) descriptors
MODES = {
# opacity
(0x00007FFE,): ("A", "L"),
# monochrome
(0x00010000,): ("L", "L"),
(0x00018000, 0x00017FFE): ("RGBA", "LA"),
# photo YCC
(0x00020000, 0x00020001, 0x00020002): ("RGB", "YCC;P"),
(0x00028000, 0x00028001, 0x00028002, 0x00027FFE): ("RGBA", "YCCA;P"),
# standard RGB (NIFRGB)
(0x00030000, 0x00030001, 0x00030002): ("RGB", "RGB"),
(0x00038000, 0x00038001, 0x00038002, 0x00037FFE): ("RGBA", "RGBA"),
}
#
# --------------------------------------------------------------------
def _accept(prefix: bytes) -> bool:
return prefix.startswith(olefile.MAGIC)
##
# Image plugin for the FlashPix images.
class FpxImageFile(ImageFile.ImageFile):
format = "FPX"
format_description = "FlashPix"
def _open(self) -> None:
#
# read the OLE directory and see if this is a likely
# to be a FlashPix file
assert self.fp is not None
try:
self.ole = olefile.OleFileIO(self.fp)
except OSError as e:
msg = "not an FPX file; invalid OLE file"
raise SyntaxError(msg) from e
root = self.ole.root
if not root or root.clsid != "56616700-C154-11CE-8553-00AA00A1F95B":
msg = "not an FPX file; bad root CLSID"
raise SyntaxError(msg)
self._open_index(1)
def _open_index(self, index: int = 1) -> None:
#
# get the Image Contents Property Set
prop = self.ole.getproperties(
[f"Data Object Store {index:06d}", "\005Image Contents"]
)
# size (highest resolution)
assert isinstance(prop[0x1000002], int)
assert isinstance(prop[0x1000003], int)
self._size = prop[0x1000002], prop[0x1000003]
size = max(self.size)
i = 1
while size > 64:
size = size // 2
i += 1
self.maxid = i - 1
# mode. instead of using a single field for this, flashpix
# requires you to specify the mode for each channel in each
# resolution subimage, and leaves it to the decoder to make
# sure that they all match. for now, we'll cheat and assume
# that this is always the case.
id = self.maxid << 16
s = prop[0x2000002 | id]
if not isinstance(s, bytes) or (bands := i32(s, 4)) > 4:
msg = "Invalid number of bands"
raise OSError(msg)
# note: for now, we ignore the "uncalibrated" flag
colors = tuple(i32(s, 8 + i * 4) & 0x7FFFFFFF for i in range(bands))
self._mode, self.rawmode = MODES[colors]
# load JPEG tables, if any
self.jpeg = {}
for i in range(256):
id = 0x3000001 | (i << 16)
if id in prop:
self.jpeg[i] = prop[id]
self._open_subimage(1, self.maxid)
def _open_subimage(self, index: int = 1, subimage: int = 0) -> None:
#
# setup tile descriptors for a given subimage
stream = [
f"Data Object Store {index:06d}",
f"Resolution {subimage:04d}",
"Subimage 0000 Header",
]
fp = self.ole.openstream(stream)
# skip prefix
fp.read(28)
# header stream
s = fp.read(36)
size = i32(s, 4), i32(s, 8)
# tilecount = i32(s, 12)
xtile, ytile = i32(s, 16), i32(s, 20)
# channels = i32(s, 24)
offset = i32(s, 28)
length = i32(s, 32)
if size != self.size:
msg = "subimage mismatch"
raise OSError(msg)
# get tile descriptors
fp.seek(28 + offset)
s = fp.read(i32(s, 12) * length)
x = y = 0
xsize, ysize = size
self.tile = []
for i in range(0, len(s), length):
x1 = min(xsize, x + xtile)
y1 = min(ysize, y + ytile)
compression = i32(s, i + 8)
if compression == 0:
self.tile.append(
ImageFile._Tile(
"raw",
(x, y, x1, y1),
i32(s, i) + 28,
self.rawmode,
)
)
elif compression == 1:
# FIXME: the fill decoder is not implemented
self.tile.append(
ImageFile._Tile(
"fill",
(x, y, x1, y1),
i32(s, i) + 28,
(self.rawmode, s[12:16]),
)
)
elif compression == 2:
internal_color_conversion = s[14]
jpeg_tables = s[15]
rawmode = self.rawmode
if internal_color_conversion:
# The image is stored as usual (usually YCbCr).
if rawmode == "RGBA":
# For "RGBA", data is stored as YCbCrA based on
# negative RGB. The following trick works around
# this problem :
jpegmode, rawmode = "YCbCrK", "CMYK"
else:
jpegmode = None # let the decoder decide
else:
# The image is stored as defined by rawmode
jpegmode = rawmode
self.tile.append(
ImageFile._Tile(
"jpeg",
(x, y, x1, y1),
i32(s, i) + 28,
(rawmode, jpegmode),
)
)
# FIXME: jpeg tables are tile dependent; the prefix
# data must be placed in the tile descriptor itself!
if jpeg_tables:
self.tile_prefix = self.jpeg[jpeg_tables]
else:
msg = "unknown/invalid compression"
raise OSError(msg)
x += xtile
if x >= xsize:
x, y = 0, y + ytile
if y >= ysize:
break # isn't really required
assert self.fp is not None
self.stream = stream
self._fp = self.fp
self.fp = None
def load(self) -> Image.core.PixelAccess | None:
if not self.fp:
self.fp = self.ole.openstream(self.stream[:2] + ["Subimage 0000 Data"])
return ImageFile.ImageFile.load(self)
def close(self) -> None:
self.ole.close()
super().close()
def __exit__(self, *args: object) -> None:
self.ole.close()
super().__exit__()
#
# --------------------------------------------------------------------
Image.register_open(FpxImageFile.format, FpxImageFile, _accept)
Image.register_extension(FpxImageFile.format, ".fpx")
@@ -0,0 +1,115 @@
"""
A Pillow loader for .ftc and .ftu files (FTEX)
Jerome Leclanche <jerome@leclan.ch>
The contents of this file are hereby released in the public domain (CC0)
Full text of the CC0 license:
https://creativecommons.org/publicdomain/zero/1.0/
Independence War 2: Edge Of Chaos - Texture File Format - 16 October 2001
The textures used for 3D objects in Independence War 2: Edge Of Chaos are in a
packed custom format called FTEX. This file format uses file extensions FTC
and FTU.
* FTC files are compressed textures (using standard texture compression).
* FTU files are not compressed.
Texture File Format
The FTC and FTU texture files both use the same format. This
has the following structure:
{header}
{format_directory}
{data}
Where:
{header} = {
u32:magic,
u32:version,
u32:width,
u32:height,
u32:mipmap_count,
u32:format_count
}
* The "magic" number is "FTEX".
* "width" and "height" are the dimensions of the texture.
* "mipmap_count" is the number of mipmaps in the texture.
* "format_count" is the number of texture formats (different versions of the
same texture) in this file.
{format_directory} = format_count * { u32:format, u32:where }
The format value is 0 for DXT1 compressed textures and 1 for 24-bit RGB
uncompressed textures.
The texture data for a format starts at the position "where" in the file.
Each set of texture data in the file has the following structure:
{data} = format_count * { u32:mipmap_size, mipmap_size * { u8 } }
* "mipmap_size" is the number of bytes in that mip level. For compressed
textures this is the size of the texture data compressed with DXT1. For 24 bit
uncompressed textures, this is 3 * width * height. Following this are the image
bytes for that mipmap level.
Note: All data is stored in little-Endian (Intel) byte order.
"""
from __future__ import annotations
import struct
from enum import IntEnum
from io import BytesIO
from . import Image, ImageFile
MAGIC = b"FTEX"
class Format(IntEnum):
DXT1 = 0
UNCOMPRESSED = 1
class FtexImageFile(ImageFile.ImageFile):
format = "FTEX"
format_description = "Texture File Format (IW2:EOC)"
def _open(self) -> None:
assert self.fp is not None
if not _accept(self.fp.read(4)):
msg = "not an FTEX file"
raise SyntaxError(msg)
struct.unpack("<i", self.fp.read(4)) # version
self._size = struct.unpack("<2i", self.fp.read(8))
mipmap_count, format_count = struct.unpack("<2i", self.fp.read(8))
# Only support single-format files.
# I don't know of any multi-format file.
assert format_count == 1
format, where = struct.unpack("<2i", self.fp.read(8))
self.fp.seek(where)
(mipmap_size,) = struct.unpack("<i", self.fp.read(4))
data = self.fp.read(mipmap_size)
if format == Format.DXT1:
self._mode = "RGBA"
self.tile = [ImageFile._Tile("bcn", (0, 0) + self.size, 0, (1,))]
elif format == Format.UNCOMPRESSED:
self._mode = "RGB"
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, "RGB")]
else:
msg = f"Invalid texture compression format: {repr(format)}"
raise ValueError(msg)
self.fp.close()
self.fp = BytesIO(data)
def load_seek(self, pos: int) -> None:
pass
def _accept(prefix: bytes) -> bool:
return prefix.startswith(MAGIC)
Image.register_open(FtexImageFile.format, FtexImageFile, _accept)
Image.register_extensions(FtexImageFile.format, [".ftc", ".ftu"])
@@ -0,0 +1,103 @@
#
# The Python Imaging Library
#
# load a GIMP brush file
#
# History:
# 96-03-14 fl Created
# 16-01-08 es Version 2
#
# Copyright (c) Secret Labs AB 1997.
# Copyright (c) Fredrik Lundh 1996.
# Copyright (c) Eric Soroos 2016.
#
# See the README file for information on usage and redistribution.
#
#
# See https://github.com/GNOME/gimp/blob/mainline/devel-docs/gbr.txt for
# format documentation.
#
# This code Interprets version 1 and 2 .gbr files.
# Version 1 files are obsolete, and should not be used for new
# brushes.
# Version 2 files are saved by GIMP v2.8 (at least)
# Version 3 files have a format specifier of 18 for 16bit floats in
# the color depth field. This is currently unsupported by Pillow.
from __future__ import annotations
from . import Image, ImageFile
from ._binary import i32be as i32
def _accept(prefix: bytes) -> bool:
return len(prefix) >= 8 and i32(prefix, 0) >= 20 and i32(prefix, 4) in (1, 2)
##
# Image plugin for the GIMP brush format.
class GbrImageFile(ImageFile.ImageFile):
format = "GBR"
format_description = "GIMP brush file"
def _open(self) -> None:
assert self.fp is not None
header_size = i32(self.fp.read(4))
if header_size < 20:
msg = "not a GIMP brush"
raise SyntaxError(msg)
version = i32(self.fp.read(4))
if version not in (1, 2):
msg = f"Unsupported GIMP brush version: {version}"
raise SyntaxError(msg)
width = i32(self.fp.read(4))
height = i32(self.fp.read(4))
color_depth = i32(self.fp.read(4))
if width == 0 or height == 0:
msg = "not a GIMP brush"
raise SyntaxError(msg)
if color_depth not in (1, 4):
msg = f"Unsupported GIMP brush color depth: {color_depth}"
raise SyntaxError(msg)
if version == 1:
comment_length = header_size - 20
else:
comment_length = header_size - 28
magic_number = self.fp.read(4)
if magic_number != b"GIMP":
msg = "not a GIMP brush, bad magic number"
raise SyntaxError(msg)
self.info["spacing"] = i32(self.fp.read(4))
self.info["comment"] = self.fp.read(comment_length)[:-1]
if color_depth == 1:
self._mode = "L"
else:
self._mode = "RGBA"
self._size = width, height
# Image might not be small
Image._decompression_bomb_check(self.size)
# Data is an uncompressed block of w * h * bytes/pixel
self._data_size = width * height * color_depth
def load(self) -> Image.core.PixelAccess | None:
if self._im is None:
assert self.fp is not None
self.im = Image.core.new(self.mode, self.size)
self.frombytes(self.fp.read(self._data_size))
return Image.Image.load(self)
#
# registry
Image.register_open(GbrImageFile.format, GbrImageFile, _accept)
Image.register_extension(GbrImageFile.format, ".gbr")
@@ -0,0 +1,103 @@
#
# The Python Imaging Library.
# $Id$
#
# GD file handling
#
# History:
# 1996-04-12 fl Created
#
# Copyright (c) 1997 by Secret Labs AB.
# Copyright (c) 1996 by Fredrik Lundh.
#
# See the README file for information on usage and redistribution.
#
"""
.. note::
This format cannot be automatically recognized, so the
class is not registered for use with :py:func:`PIL.Image.open()`. To open a
gd file, use the :py:func:`PIL.GdImageFile.open()` function instead.
.. warning::
THE GD FORMAT IS NOT DESIGNED FOR DATA INTERCHANGE. This
implementation is provided for convenience and demonstrational
purposes only.
"""
from __future__ import annotations
from typing import IO
from . import ImageFile, ImagePalette, UnidentifiedImageError
from ._binary import i16be as i16
from ._binary import i32be as i32
from ._typing import StrOrBytesPath
class GdImageFile(ImageFile.ImageFile):
"""
Image plugin for the GD uncompressed format. Note that this format
is not supported by the standard :py:func:`PIL.Image.open()` function. To use
this plugin, you have to import the :py:mod:`PIL.GdImageFile` module and
use the :py:func:`PIL.GdImageFile.open()` function.
"""
format = "GD"
format_description = "GD uncompressed images"
def _open(self) -> None:
# Header
assert self.fp is not None
s = self.fp.read(1037)
if i16(s) not in [65534, 65535]:
msg = "Not a valid GD 2.x .gd file"
raise SyntaxError(msg)
self._mode = "P"
self._size = i16(s, 2), i16(s, 4)
true_color = s[6]
true_color_offset = 2 if true_color else 0
# transparency index
tindex = i32(s, 7 + true_color_offset)
if tindex < 256:
self.info["transparency"] = tindex
self.palette = ImagePalette.raw(
"RGBX", s[7 + true_color_offset + 6 : 7 + true_color_offset + 6 + 256 * 4]
)
self.tile = [
ImageFile._Tile(
"raw",
(0, 0) + self.size,
7 + true_color_offset + 6 + 256 * 4,
"L",
)
]
def open(fp: StrOrBytesPath | IO[bytes], mode: str = "r") -> GdImageFile:
"""
Load texture from a GD image file.
:param fp: GD file name, or an opened file handle.
:param mode: Optional mode. In this version, if the mode argument
is given, it must be "r".
:returns: An image instance.
:raises OSError: If the image could not be read.
"""
if mode != "r":
msg = "bad mode"
raise ValueError(msg)
try:
return GdImageFile(fp)
except SyntaxError as e:
msg = "cannot identify this image file"
raise UnidentifiedImageError(msg) from e
@@ -0,0 +1,154 @@
#
# Python Imaging Library
# $Id$
#
# stuff to read (and render) GIMP gradient files
#
# History:
# 97-08-23 fl Created
#
# Copyright (c) Secret Labs AB 1997.
# Copyright (c) Fredrik Lundh 1997.
#
# See the README file for information on usage and redistribution.
#
"""
Stuff to translate curve segments to palette values (derived from
the corresponding code in GIMP, written by Federico Mena Quintero.
See the GIMP distribution for more information.)
"""
from __future__ import annotations
from math import log, pi, sin, sqrt
from ._binary import o8
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable
from typing import IO
EPSILON = 1e-10
"""""" # Enable auto-doc for data member
def linear(middle: float, pos: float) -> float:
if pos <= middle:
if middle < EPSILON:
return 0.0
else:
return 0.5 * pos / middle
else:
pos = pos - middle
middle = 1.0 - middle
if middle < EPSILON:
return 1.0
else:
return 0.5 + 0.5 * pos / middle
def curved(middle: float, pos: float) -> float:
return pos ** (log(0.5) / log(max(middle, EPSILON)))
def sine(middle: float, pos: float) -> float:
return (sin((-pi / 2.0) + pi * linear(middle, pos)) + 1.0) / 2.0
def sphere_increasing(middle: float, pos: float) -> float:
return sqrt(1.0 - (linear(middle, pos) - 1.0) ** 2)
def sphere_decreasing(middle: float, pos: float) -> float:
return 1.0 - sqrt(1.0 - linear(middle, pos) ** 2)
SEGMENTS = [linear, curved, sine, sphere_increasing, sphere_decreasing]
"""""" # Enable auto-doc for data member
class GradientFile:
gradient: (
list[
tuple[
float,
float,
float,
list[float],
list[float],
Callable[[float, float], float],
]
]
| None
) = None
def getpalette(self, entries: int = 256) -> tuple[bytes, str]:
assert self.gradient is not None
palette = []
ix = 0
x0, x1, xm, rgb0, rgb1, segment = self.gradient[ix]
for i in range(entries):
x = i / (entries - 1)
while x1 < x:
ix += 1
x0, x1, xm, rgb0, rgb1, segment = self.gradient[ix]
w = x1 - x0
if w < EPSILON:
scale = segment(0.5, 0.5)
else:
scale = segment((xm - x0) / w, (x - x0) / w)
# expand to RGBA
r = o8(int(255 * ((rgb1[0] - rgb0[0]) * scale + rgb0[0]) + 0.5))
g = o8(int(255 * ((rgb1[1] - rgb0[1]) * scale + rgb0[1]) + 0.5))
b = o8(int(255 * ((rgb1[2] - rgb0[2]) * scale + rgb0[2]) + 0.5))
a = o8(int(255 * ((rgb1[3] - rgb0[3]) * scale + rgb0[3]) + 0.5))
# add to palette
palette.append(r + g + b + a)
return b"".join(palette), "RGBA"
class GimpGradientFile(GradientFile):
"""File handler for GIMP's gradient format."""
def __init__(self, fp: IO[bytes]) -> None:
if not fp.readline().startswith(b"GIMP Gradient"):
msg = "not a GIMP gradient file"
raise SyntaxError(msg)
line = fp.readline()
# GIMP 1.2 gradient files don't contain a name, but GIMP 1.3 files do
if line.startswith(b"Name: "):
line = fp.readline().strip()
count = int(line)
self.gradient = []
for i in range(count):
s = fp.readline().split()
w = [float(x) for x in s[:11]]
x0, x1 = w[0], w[2]
xm = w[1]
rgb0 = w[3:7]
rgb1 = w[7:11]
segment = SEGMENTS[int(s[11])]
cspace = int(s[12])
if cspace != 0:
msg = "cannot handle HSV colour space"
raise OSError(msg)
self.gradient.append((x0, x1, xm, rgb0, rgb1, segment))

Some files were not shown because too many files have changed in this diff Show More