chore: 初始化项目结构并添加基础配置文件

添加后端API和前端项目的初始目录结构,包括应用层模块、工具模块和模型定义
配置前后端的.dockerignore文件以排除开发环境文件
添加前端样式文件,包括字体、Tailwind CSS和主题配置
初始化Python虚拟环境中的类型标记文件
This commit is contained in:
2026-04-01 16:55:24 +08:00
commit cfdeeb5e41
4149 changed files with 837587 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
.env
__pycache__
*.pyc
.git
uploads/*
!uploads/.gitkeep

View File

@@ -0,0 +1,29 @@
# MongoDB
MONGODB_URL=mongodb://localhost:27017
MONGODB_DB_NAME=pregnancy_report
# JWT
JWT_SECRET_KEY=your-secret-key-change-this
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=10080
# WeChat Mini Program
WECHAT_APP_ID=your-wechat-app-id
WECHAT_APP_SECRET=your-wechat-app-secret
# Claude AI
ANTHROPIC_API_KEY=your-anthropic-api-key
ANTHROPIC_BASE_URL=
AI_MODEL_NAME=claude-sonnet-4-20250514
# CORS(多个域名逗号分隔,* 表示允许所有)
CORS_ORIGINS=*
# File Upload
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=10485760
# Server
HOST=0.0.0.0
PORT=8000
DEBUG=true

View File

@@ -0,0 +1,24 @@
# Environment
.env
venv/
# Python
__pycache__/
*.pyc
*.pyo
*.egg-info/
dist/
build/
# Uploads
uploads/
# IDE
.vscode/
.idea/
*.swp
*.swo
.DS_Store
# Claude Code
.claude/

View File

@@ -0,0 +1,20 @@
FROM python:3.11-slim
WORKDIR /app
RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && pip config set global.trusted-host mirrors.aliyun.com
RUN apt-get update && apt-get install -y --no-install-recommends \
libmagic1 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /app/uploads
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,39 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# MongoDB
mongodb_url: str = "mongodb://localhost:27017"
mongodb_db_name: str = "pregnancy_report"
# JWT
jwt_secret_key: str = "change-this-secret-key"
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 10080 # 7 days
# WeChat
wechat_app_id: str = ""
wechat_app_secret: str = ""
# AI API (OpenAI 兼容接口,如硅基流动)
ai_api_key: str = ""
ai_base_url: str = "https://api.siliconflow.cn/v1"
ai_vision_model: str = "Qwen/Qwen2.5-VL-72B-Instruct"
ai_text_model: str = "Qwen/Qwen2.5-72B-Instruct" # 使用更快的72B模型
# Upload
upload_dir: str = "./uploads"
max_file_size: int = 10 * 1024 * 1024 # 10MB
# CORS
cors_origins: str = "*" # 多个域名用逗号分隔,如 https://a.com,https://b.com
# Server
host: str = "0.0.0.0"
port: int = 8000
debug: bool = True
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
settings = Settings()

View File

@@ -0,0 +1,38 @@
import os
import certifi
from beanie import init_beanie
from app.config import settings
from app.models.user import User
from app.models.report import Report
from app.models.analysis import Analysis
from app.models.chat import ChatSession
from app.models.checkup import CheckupPlan
async def init_db():
use_mock = os.environ.get("USE_MONGOMOCK", "").lower() in ("1", "true", "yes")
if use_mock:
# 使用 mongomock 内存数据库(无需安装 MongoDB)
from mongomock_motor import AsyncMongoMockClient
client = AsyncMongoMockClient()
else:
from motor.motor_asyncio import AsyncIOMotorClient
# Only use TLS for Atlas (mongodb+srv://) connections
if settings.mongodb_url.startswith("mongodb+srv://") or "mongodb.net" in settings.mongodb_url:
client = AsyncIOMotorClient(settings.mongodb_url, tlsCAFile=certifi.where())
else:
client = AsyncIOMotorClient(settings.mongodb_url)
await init_beanie(
database=client[settings.mongodb_db_name],
document_models=[
User,
Report,
Analysis,
ChatSession,
CheckupPlan,
],
)

View File

@@ -0,0 +1,114 @@
import logging
import os
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from app.config import settings
from app.database import init_db
from app.routers import (
auth,
user,
report,
analysis,
chat,
health_records,
calendar,
doctor,
share,
)
# 日志配置
logging.basicConfig(
level=logging.DEBUG if settings.debug else logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("孕育无忧")
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("服务启动中...")
await init_db()
os.makedirs(settings.upload_dir, exist_ok=True)
logger.info("服务启动完成 http://%s:%s", settings.host, settings.port)
yield
logger.info("服务关闭")
app = FastAPI(
title="孕育无忧 API",
description="孕期健康检查报告解读后端服务",
version="1.0.0",
lifespan=lifespan,
)
# CORS 配置
cors_origins = (
["*"]
if settings.cors_origins == "*"
else [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
)
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 请求日志中间件
@app.middleware("http")
async def log_requests(request: Request, call_next):
start = time.time()
response = await call_next(request)
duration = round((time.time() - start) * 1000)
logger.info(
"%s %s → %s (%dms)",
request.method,
request.url.path,
response.status_code,
duration,
)
return response
# 全局异常处理
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
logger.error("未处理异常 %s %s: %s", request.method, request.url.path, exc, exc_info=True)
return JSONResponse(
status_code=500,
content={"detail": "服务器内部错误,请稍后重试"},
)
app.mount("/uploads", StaticFiles(directory=settings.upload_dir), name="uploads")
app.include_router(auth.router, prefix="/api/auth", tags=["认证"])
app.include_router(user.router, prefix="/api/user", tags=["用户"])
app.include_router(report.router, prefix="/api/report", tags=["报告"])
app.include_router(analysis.router, prefix="/api/analysis", tags=["AI解读"])
app.include_router(chat.router, prefix="/api/chat", tags=["AI对话"])
app.include_router(health_records.router, prefix="/api/health-records", tags=["健康档案"])
app.include_router(calendar.router, prefix="/api/calendar", tags=["产检日历"])
app.include_router(doctor.router, prefix="/api/doctor", tags=["专家咨询"])
app.include_router(share.router, prefix="/api/share", tags=["分享"])
@app.get("/api/health")
async def health_check():
return {"status": "ok", "service": "孕育无忧 API"}
@app.get("/")
async def web_demo():
"""Web 体验页面"""
return FileResponse(
os.path.join(os.path.dirname(__file__), "static", "index.html")
)

View File

@@ -0,0 +1,36 @@
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from app.config import settings
from app.models.user import User
security = HTTPBearer()
def create_access_token(user_id: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
to_encode = {"sub": user_id, "exp": expire}
return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> User:
token = credentials.credentials
try:
payload = jwt.decode(
token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm]
)
user_id: str = payload.get("sub")
if user_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证信息")
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token已过期或无效")
user = await User.get(user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
return user

View File

@@ -0,0 +1,60 @@
from datetime import datetime, timezone
from typing import Optional
from beanie import Document, PydanticObjectId
from pydantic import BaseModel, Field
class IndicatorInterpretation(BaseModel):
"""单项指标解读"""
name: str
nickname: str = "" # 白话名称,如"氧气快递员"
value: str = ""
status: str = "normal"
what_is_it: str = "" # 通俗解释
impact: str = "" # 对宝宝的影响
suggestion: str = "" # 行动建议
recheck_reminder: Optional[str] = None
class CategoryInterpretation(BaseModel):
"""分类解读(如血液指标、激素水平)"""
category: str
status: str = "normal"
indicators: list[IndicatorInterpretation] = Field(default_factory=list)
class ActionItem(BaseModel):
"""行动建议"""
content: str
priority: str = "medium" # high / medium / low
category: str = "" # 饮食 / 运动 / 就医 / 复查
class Analysis(Document):
report_id: PydanticObjectId
user_id: PydanticObjectId
# Overall assessment
overall_status: str = "normal" # normal / attention / warning
risk_level: str = "low" # low / medium / high
overall_summary: str = "" # 整体评估文案
emotional_summary: str = "" # 情感化总结,如"宝宝发育良好"
# Detailed interpretations
categories: list[CategoryInterpretation] = Field(default_factory=list)
# Action items
action_items: list[ActionItem] = Field(default_factory=list)
# Tips
tips: list[str] = Field(default_factory=list)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Settings:
name = "analyses"
indexes = [
"user_id",
"report_id",
]

View File

@@ -0,0 +1,24 @@
from datetime import datetime, timezone
from beanie import Document, PydanticObjectId
from pydantic import BaseModel, Field
class ChatMessage(BaseModel):
role: str # user / assistant
content: str
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class ChatSession(Document):
user_id: PydanticObjectId
report_id: PydanticObjectId
messages: list[ChatMessage] = Field(default_factory=list)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Settings:
name = "chat_sessions"
indexes = [
[("user_id", 1), ("report_id", 1)],
]

View File

@@ -0,0 +1,30 @@
from datetime import datetime, timezone
from typing import Optional
from beanie import Document, PydanticObjectId
from pydantic import BaseModel, Field
class CheckupItem(BaseModel):
title: str
week: int
date: Optional[str] = None
items: list[str] = Field(default_factory=list)
preparations: list[str] = Field(default_factory=list)
notes: str = ""
status: str = "upcoming" # upcoming / completed / missed
importance: str = "medium" # high / medium / low
report_ids: list[str] = Field(default_factory=list) # linked report IDs
class CheckupPlan(Document):
user_id: PydanticObjectId
checkups: list[CheckupItem] = Field(default_factory=list)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Settings:
name = "checkup_plans"
indexes = [
"user_id",
]

View File

@@ -0,0 +1,50 @@
from datetime import datetime, timezone
from typing import Optional
from beanie import Document, PydanticObjectId
from pydantic import BaseModel, Field
class DataItem(BaseModel):
"""单个检查指标"""
name: str
value: str
unit: str = ""
reference_range: str = ""
status: str = "normal" # normal / high / low
category: str = "" # 血液检查 / 激素水平 / 尿常规 etc.
class Report(Document):
user_id: PydanticObjectId
report_type: str = "" # 血常规 / B超 / 尿常规 etc.
pregnancy_week: Optional[int] = None
report_date: Optional[str] = None # 报告日期 YYYY-MM-DD
# Images
image_urls: list[str] = Field(default_factory=list)
# OCR results
ocr_raw_text: Optional[str] = None
data_items: list[DataItem] = Field(default_factory=list)
# User additions
symptoms: Optional[list[str]] = None # 自觉症状
notes: Optional[str] = None # 备注
# Privacy fields (auto-masked)
patient_name: Optional[str] = None
patient_id_masked: Optional[str] = None
# Status
status: str = "pending" # pending / verified / analyzed
overall_status: str = "normal" # normal / attention / warning
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Settings:
name = "reports"
indexes = [
"user_id",
]

View File

@@ -0,0 +1,55 @@
from datetime import datetime, timezone
from typing import Optional
from beanie import Document
from pymongo import IndexModel, ASCENDING
from pydantic import BaseModel, Field
class EmergencyContact(BaseModel):
name: str = ""
relation: str = ""
phone: str = ""
class User(Document):
openid: str = Field(..., unique=True)
union_id: Optional[str] = None
session_key: Optional[str] = None
# Basic info
name: str = ""
phone: Optional[str] = None
age: Optional[int] = None
height: Optional[float] = None # cm
pre_pregnancy_weight: Optional[float] = None # kg
current_weight: Optional[float] = None # kg
blood_type: Optional[str] = None
allergies: list[str] = Field(default_factory=list)
medical_history: list[str] = Field(default_factory=list)
# Pregnancy info
last_period_date: Optional[datetime] = None
due_date: Optional[datetime] = None
pregnancy_week: Optional[int] = None
pregnancy_day: Optional[int] = None
multiple_pregnancy: Optional[str] = None # 单胎/双胎/三胎+
pregnancy_count: Optional[int] = None # 第几次怀孕
emergency_contact: EmergencyContact = Field(default_factory=EmergencyContact)
# Status
is_onboarded: bool = False
avatar_url: Optional[str] = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Settings:
name = "users"
indexes = [
IndexModel(
[("phone", ASCENDING)],
unique=True,
partialFilterExpression={"phone": {"$type": "string"}},
),
]

View File

@@ -0,0 +1,9 @@
from app.routers.auth import router as auth_router
from app.routers.user import router as user_router
from app.routers.report import router as report_router
from app.routers.analysis import router as analysis_router
from app.routers.chat import router as chat_router
from app.routers.health_records import router as health_records_router
from app.routers.calendar import router as calendar_router
from app.routers.doctor import router as doctor_router
from app.routers.share import router as share_router

View File

@@ -0,0 +1,147 @@
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from app.middleware.auth import get_current_user
from app.models.analysis import (
Analysis,
CategoryInterpretation,
IndicatorInterpretation,
ActionItem,
)
from app.models.report import Report
from app.models.user import User
from app.services.ai_service import analyze_report
from app.routers.calendar import link_report_to_checkup
from app.utils.helpers import parse_object_id
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/{report_id}/generate")
async def generate_analysis(
report_id: str, user: User = Depends(get_current_user)
):
"""生成 AI 解读"""
oid = parse_object_id(report_id)
report = await Report.get(oid)
if not report or report.user_id != user.id:
raise HTTPException(status_code=404, detail="报告不存在")
# Check if analysis already exists
existing = await Analysis.find_one(Analysis.report_id == report.id)
if existing:
return _analysis_to_dict(existing)
# Call Claude API
try:
result = await analyze_report(report, user)
except Exception:
logger.exception("AI 解读失败 report_id=%s", report_id)
raise HTTPException(status_code=502, detail="AI 解读服务暂时不可用,请稍后重试")
# Save analysis
categories = []
for cat in result.get("categories", []):
indicators = [
IndicatorInterpretation(**ind) for ind in cat.get("indicators", [])
]
categories.append(
CategoryInterpretation(
category=cat.get("category", ""),
status=cat.get("status", "normal"),
indicators=indicators,
)
)
action_items = [ActionItem(**a) for a in result.get("action_items", [])]
analysis = Analysis(
report_id=report.id,
user_id=user.id,
overall_status=result.get("overall_status", "normal"),
risk_level=result.get("risk_level", "low"),
overall_summary=result.get("overall_summary", ""),
emotional_summary=result.get("emotional_summary", ""),
categories=categories,
action_items=action_items,
tips=result.get("tips", []),
)
await analysis.insert()
# Update report status
report.status = "analyzed"
report.overall_status = analysis.overall_status
report.updated_at = datetime.now(timezone.utc)
await report.save()
# Auto-link report to the nearest calendar checkup
if report.pregnancy_week:
try:
await link_report_to_checkup(user.id, str(report.id), report.pregnancy_week)
except Exception:
logger.warning("自动关联日历失败 report_id=%s", report_id)
return _analysis_to_dict(analysis)
@router.get("/{report_id}")
async def get_analysis(report_id: str, user: User = Depends(get_current_user)):
"""获取报告的 AI 解读结果"""
oid = parse_object_id(report_id)
report = await Report.get(oid)
if not report or report.user_id != user.id:
raise HTTPException(status_code=404, detail="报告不存在")
analysis = await Analysis.find_one(Analysis.report_id == report.id)
if not analysis:
raise HTTPException(status_code=404, detail="暂无解读结果,请先生成")
return _analysis_to_dict(analysis)
def _analysis_to_dict(analysis: Analysis) -> dict:
return {
"id": str(analysis.id),
"report_id": str(analysis.report_id),
"overall_status": analysis.overall_status,
"risk_level": analysis.risk_level,
"overall_summary": analysis.overall_summary,
"emotional_summary": analysis.emotional_summary,
"categories": [
{
"category": cat.category,
"status": cat.status,
"indicators": [
{
"name": ind.name,
"nickname": ind.nickname,
"value": ind.value,
"unit": "",
"reference_range": "",
"status": ind.status,
"what_is_it": ind.what_is_it,
"baby_impact": ind.impact,
"suggestion": ind.suggestion,
"follow_up": ind.recheck_reminder or "",
}
for ind in cat.indicators
],
}
for cat in analysis.categories
],
"action_items": [
{
"priority": a.priority,
"title": a.content,
"description": a.category,
"timeframe": "",
}
for a in analysis.action_items
],
"tips": analysis.tips,
"created_at": analysis.created_at.isoformat(),
}

View File

@@ -0,0 +1,87 @@
import uuid
from fastapi import APIRouter, HTTPException
from pymongo.errors import DuplicateKeyError
from app.models.user import User
from app.schemas.auth import WechatLoginRequest, PhoneLoginRequest, TokenResponse
from app.middleware.auth import create_access_token
from app.services.wechat import code_to_session
router = APIRouter()
@router.post("/demo-login", response_model=TokenResponse)
async def demo_login():
"""Web 体验用的演示登录,每次创建独立的临时用户"""
demo_openid = f"demo_{uuid.uuid4().hex[:12]}"
user = User(
openid=demo_openid,
name="体验用户",
)
await user.insert()
token = create_access_token(str(user.id))
return TokenResponse(
access_token=token,
user_id=str(user.id),
is_new_user=True,
)
@router.post("/wechat-login", response_model=TokenResponse)
async def wechat_login(req: WechatLoginRequest):
"""微信小程序登录"""
try:
wechat_data = await code_to_session(req.code)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
openid = wechat_data["openid"]
user = await User.find_one(User.openid == openid)
is_new = user is None
if not user:
user = User(
openid=openid,
session_key=wechat_data.get("session_key"),
union_id=wechat_data.get("unionid"),
)
await user.insert()
else:
user.session_key = wechat_data.get("session_key")
await user.save()
token = create_access_token(str(user.id))
return TokenResponse(
access_token=token,
user_id=str(user.id),
is_new_user=is_new,
)
@router.post("/phone-login", response_model=TokenResponse)
async def phone_login(req: PhoneLoginRequest):
"""手机号登录(简化版,生产环境需接入短信验证)"""
# TODO: 接入真实短信验证服务
if req.sms_code != "123456":
raise HTTPException(status_code=400, detail="验证码错误")
user = await User.find_one(User.phone == req.phone)
is_new = user is None
if not user:
try:
user = User(openid=f"phone_{req.phone}", phone=req.phone)
await user.insert()
except DuplicateKeyError:
# 并发竞态:另一个请求已创建该用户,回退查找
user = await User.find_one(User.phone == req.phone)
is_new = False
token = create_access_token(str(user.id))
return TokenResponse(
access_token=token,
user_id=str(user.id),
is_new_user=is_new,
)

View File

@@ -0,0 +1,101 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from app.middleware.auth import get_current_user
from app.models.checkup import CheckupPlan, CheckupItem
from app.models.user import User
router = APIRouter()
# 标准产检计划
STANDARD_CHECKUPS = [
CheckupItem(title="首次产检", week=6, items=["B超确认宫内孕", "血常规", "尿常规", "肝功能", "肾功能"], preparations=["空腹", "带身份证和医保卡"], notes="确认怀孕和预产期", importance="high"),
CheckupItem(title="NT检查", week=12, items=["NT超声筛查", "血清学筛查", "血压", "体重"], preparations=["不需要空腹", "需憋尿"], notes="筛查唐氏综合征风险", importance="high"),
CheckupItem(title="唐氏筛查", week=16, items=["唐氏筛查抽血", "血压", "体重", "宫高腹围"], preparations=["空腹抽血"], notes="评估胎儿染色体异常风险", importance="high"),
CheckupItem(title="大排畸检查", week=22, items=["四维彩超", "血压", "体重", "宫高腹围"], preparations=["不需要空腹", "提前预约"], notes="全面筛查胎儿结构畸形", importance="high"),
CheckupItem(title="糖耐量检查", week=24, items=["OGTT糖耐量试验", "血压", "体重"], preparations=["空腹8-14小时", "检查前3天正常饮食"], notes="筛查妊娠期糖尿病", importance="high"),
CheckupItem(title="常规产检", week=28, items=["血常规", "尿常规", "血压", "体重", "胎心监护"], preparations=["空腹"], notes="进入孕晚期,增加产检频率", importance="medium"),
CheckupItem(title="常规产检", week=30, items=["B超评估胎儿发育", "血压", "体重", "胎心监护"], preparations=[], notes="评估胎儿生长发育", importance="medium"),
CheckupItem(title="常规产检", week=32, items=["血常规", "尿常规", "血压", "体重", "胎心监护", "B超"], preparations=["空腹"], notes="观察胎位", importance="medium"),
CheckupItem(title="GBS筛查", week=36, items=["B族链球菌筛查", "血压", "体重", "胎心监护", "内检"], preparations=[], notes="预防新生儿感染", importance="high"),
CheckupItem(title="每周产检", week=37, items=["胎心监护", "血压", "体重", "宫高腹围"], preparations=[], notes="每周一次,准备待产", importance="medium"),
CheckupItem(title="每周产检", week=38, items=["胎心监护", "B超评估", "血压", "体重"], preparations=[], notes="评估分娩时机", importance="medium"),
CheckupItem(title="每周产检", week=39, items=["胎心监护", "血压", "体重", "宫颈评估"], preparations=[], notes="关注产兆", importance="high"),
CheckupItem(title="预产期产检", week=40, items=["胎心监护", "B超", "血压", "体重", "催产评估"], preparations=[], notes="超过预产期需密切监护", importance="high"),
]
@router.get("/plan")
async def get_checkup_plan(user: User = Depends(get_current_user)):
"""获取产检计划"""
plan = await CheckupPlan.find_one(CheckupPlan.user_id == user.id)
if not plan:
# Generate default plan
plan = CheckupPlan(user_id=user.id, checkups=STANDARD_CHECKUPS)
await plan.insert()
# Mark completed checkups based on current week
current_week = user.pregnancy_week or 0
checkups = []
for c in plan.checkups:
status = c.status
if c.week < current_week and status == "upcoming":
status = "missed"
checkups.append({
**c.model_dump(),
"status": status,
})
# Find next checkup
next_checkup = None
for c in checkups:
if c["status"] == "upcoming":
next_checkup = c
break
return {
"checkups": checkups,
"next_checkup": next_checkup,
"current_week": current_week,
}
@router.put("/plan/{week}/complete")
async def complete_checkup(week: int, user: User = Depends(get_current_user)):
"""标记产检已完成"""
plan = await CheckupPlan.find_one(CheckupPlan.user_id == user.id)
if not plan:
raise HTTPException(status_code=404, detail="产检计划不存在")
for checkup in plan.checkups:
if checkup.week == week:
checkup.status = "completed"
break
plan.updated_at = datetime.now(timezone.utc)
await plan.save()
return {"message": "已标记完成"}
async def link_report_to_checkup(user_id, report_id: str, pregnancy_week: int):
"""将报告自动关联到最近的产检(±2周窗口),并标记该产检为已完成"""
plan = await CheckupPlan.find_one(CheckupPlan.user_id == user_id)
if not plan:
return
best = None
best_diff = 999
for c in plan.checkups:
diff = abs(c.week - pregnancy_week)
if diff <= 2 and diff < best_diff:
best = c
best_diff = diff
if best:
if report_id not in best.report_ids:
best.report_ids.append(report_id)
best.status = "completed"
plan.updated_at = datetime.now(timezone.utc)
await plan.save()

View File

@@ -0,0 +1,98 @@
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from app.middleware.auth import get_current_user
from app.models.chat import ChatSession, ChatMessage
from app.models.report import Report
from app.models.user import User
from app.schemas.chat import ChatMessageRequest, ChatSessionResponse, ChatMessageResponse
from app.services.ai_service import chat_with_ai
from app.utils.helpers import parse_object_id
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/{report_id}/message")
async def send_message(
report_id: str,
req: ChatMessageRequest,
user: User = Depends(get_current_user),
):
"""发送消息并获取 AI 回复"""
oid = parse_object_id(report_id)
report = await Report.get(oid)
if not report or report.user_id != user.id:
raise HTTPException(status_code=404, detail="报告不存在")
# Get or create chat session
session = await ChatSession.find_one(
ChatSession.user_id == user.id,
ChatSession.report_id == report.id,
)
if not session:
session = ChatSession(user_id=user.id, report_id=report.id)
await session.insert()
# Add user message
user_msg = ChatMessage(role="user", content=req.content)
session.messages.append(user_msg)
# Get AI response
history = [{"role": m.role, "content": m.content} for m in session.messages[:-1]]
try:
ai_response = await chat_with_ai(report, user, history, req.content)
except Exception:
logger.exception("AI 对话失败 report_id=%s", report_id)
raise HTTPException(status_code=502, detail="AI 对话服务暂时不可用,请稍后重试")
# Add AI message
ai_msg = ChatMessage(role="assistant", content=ai_response)
session.messages.append(ai_msg)
session.updated_at = datetime.now(timezone.utc)
await session.save()
return {
"user_message": {"role": "user", "content": req.content, "timestamp": user_msg.timestamp.isoformat()},
"ai_message": {"role": "assistant", "content": ai_response, "timestamp": ai_msg.timestamp.isoformat()},
}
@router.get("/{report_id}/history")
async def get_chat_history(
report_id: str, user: User = Depends(get_current_user)
):
"""获取聊天历史"""
oid = parse_object_id(report_id)
report = await Report.get(oid)
if not report or report.user_id != user.id:
raise HTTPException(status_code=404, detail="报告不存在")
session = await ChatSession.find_one(
ChatSession.user_id == user.id,
ChatSession.report_id == report.id,
)
if not session:
return {"messages": [], "quick_questions": _get_quick_questions()}
return {
"messages": [
{"role": m.role, "content": m.content, "timestamp": m.timestamp.isoformat()}
for m in session.messages
],
"quick_questions": _get_quick_questions(),
}
def _get_quick_questions() -> list[str]:
return [
"这个指标偏高会影响宝宝吗?",
"我需要注意什么饮食?",
"什么时候需要复查?",
"这个结果严重吗?",
"有什么改善建议?",
]

View File

@@ -0,0 +1,96 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
from app.middleware.auth import get_current_user
from app.models.report import Report
from app.models.user import User
from app.services.ai_service import generate_doctor_questions
from app.utils.helpers import parse_object_id
logger = logging.getLogger(__name__)
router = APIRouter()
# 预设医生列表
DOCTORS = [
{
"id": "1",
"name": "王丽华",
"title": "主任医师",
"hospital": "北京协和医院",
"specialty": "高危妊娠、产前诊断",
"experience": "25年",
"rating": 4.9,
"review_count": 1280,
"consult_count": 5600,
"response_time": "30分钟内",
"price": {"text": 50, "video": 200, "urgent": 100},
"tags": ["高危妊娠", "产前诊断", "遗传咨询"],
},
{
"id": "2",
"name": "张美琳",
"title": "副主任医师",
"hospital": "上海第一妇婴保健院",
"specialty": "孕期营养、妊娠期糖尿病",
"experience": "18年",
"rating": 4.8,
"review_count": 960,
"consult_count": 3800,
"response_time": "1小时内",
"price": {"text": 40, "video": 180, "urgent": 80},
"tags": ["孕期营养", "妊娠糖尿病", "体重管理"],
},
{
"id": "3",
"name": "刘晓芳",
"title": "主任医师",
"hospital": "广州市妇女儿童医疗中心",
"specialty": "超声诊断、胎儿医学",
"experience": "20年",
"rating": 4.9,
"review_count": 1100,
"consult_count": 4200,
"response_time": "2小时内",
"price": {"text": 45, "video": 190, "urgent": 90},
"tags": ["超声诊断", "胎儿医学", "产前筛查"],
},
]
@router.get("/questions/{report_id}")
async def get_doctor_questions(
report_id: str, user: User = Depends(get_current_user)
):
"""生成问诊清单"""
oid = parse_object_id(report_id)
report = await Report.get(oid)
if not report or report.user_id != user.id:
raise HTTPException(status_code=404, detail="报告不存在")
try:
result = await generate_doctor_questions(report, user)
except Exception:
logger.exception("问诊清单生成失败 report_id=%s", report_id)
raise HTTPException(status_code=502, detail="AI 服务暂时不可用,请稍后重试")
return {
"report_id": report_id,
"questions": result.get("questions", []),
}
@router.get("/experts")
async def list_experts():
"""获取专家医生列表"""
return {"doctors": DOCTORS}
@router.get("/experts/{doctor_id}")
async def get_expert_detail(doctor_id: str):
"""获取医生详情"""
for doc in DOCTORS:
if doc["id"] == doctor_id:
return doc
raise HTTPException(status_code=404, detail="医生不存在")

View File

@@ -0,0 +1,258 @@
import re
from typing import Optional, Tuple
from beanie import PydanticObjectId
from fastapi import APIRouter, Depends
from app.middleware.auth import get_current_user
from app.models.report import Report
from app.models.user import User
router = APIRouter()
def _parse_numeric_value(value) -> Optional[float]:
value_str = str(value).strip() if value is not None else ""
if not value_str:
return None
match = re.search(r'(\d+\.?\d*)', value_str)
if not match:
return None
try:
return float(match.group(1))
except (ValueError, AttributeError):
return None
def _parse_reference_range(reference_range: str) -> Tuple[Optional[float], Optional[float]]:
if not reference_range:
return None, None
match = re.search(r'(\d+\.?\d*)\s*[-~]\s*(\d+\.?\d*)', reference_range)
if not match:
return None, None
return float(match.group(1)), float(match.group(2))
@router.get("/overview")
async def get_health_overview(user: User = Depends(get_current_user)):
"""健康档案概览"""
reports = await Report.find(Report.user_id == user.id).sort("-created_at").to_list()
total = len(reports)
normal = sum(1 for r in reports if r.overall_status == "normal")
attention = sum(1 for r in reports if r.overall_status == "attention")
warning = sum(1 for r in reports if r.overall_status == "warning")
recent = [
{
"id": str(r.id),
"report_type": r.report_type,
"overall_status": r.overall_status,
"created_at": r.created_at.isoformat(),
}
for r in reports[:5]
]
return {
"stats": {
"total": total,
"normal": normal,
"attention": attention,
"warning": warning,
},
"recent_reports": recent,
}
@router.get("/growth-curve")
async def get_growth_curve(user: User = Depends(get_current_user)):
"""获取胎儿生长曲线数据(从B超报告提取)"""
reports = await Report.find(
Report.user_id == user.id,
Report.report_type == "B超",
).sort("pregnancy_week").to_list()
data_points = []
for r in reports:
if not r.pregnancy_week:
continue
point = {"week": r.pregnancy_week}
for item in r.data_items:
name_lower = item.name.lower()
numeric_value = _parse_numeric_value(item.value)
if numeric_value is None:
continue
# 匹配指标名称
if "双顶径" in item.name or "bpd" in name_lower:
point["bpd"] = numeric_value
elif "腹围" in item.name or "ac" in name_lower:
point["ac"] = numeric_value
elif "股骨长" in item.name or "fl" in name_lower:
point["fl"] = numeric_value
# 只有包含至少一个生长指标的数据点才添加
if any(k in point for k in ["bpd", "ac", "fl"]):
data_points.append(point)
return {"data_points": data_points}
@router.get("/timeline")
async def get_timeline(user: User = Depends(get_current_user)):
"""报告时间轴"""
reports = await Report.find(Report.user_id == user.id).sort("-created_at").to_list()
timeline = []
for r in reports:
week = r.pregnancy_week or 0
if week <= 12:
period = "early"
elif week <= 28:
period = "mid"
else:
period = "late"
timeline.append({
"id": str(r.id),
"report_type": r.report_type,
"date": r.created_at.isoformat(),
"status": r.overall_status,
"summary": f"{len(r.data_items)}项指标",
"period": period,
"week": week,
})
return {"reports": timeline}
@router.get("/indicator-trends")
async def get_indicator_trends(user: User = Depends(get_current_user)):
"""获取跨报告指标趋势数据(同名数值指标出现2+次的)"""
reports = await Report.find(
Report.user_id == user.id,
).sort("pregnancy_week", "created_at").to_list()
# 收集所有指标:indicator_name -> list of {week, value, unit, ref_range, status, date, report_type}
indicator_map: dict[str, list[dict]] = {}
for r in reports:
week = r.pregnancy_week or 0
date_str = r.created_at.isoformat() if r.created_at else ""
for item in r.data_items:
value_str = str(item.value).strip() if item.value else ""
if not value_str:
continue
match = re.search(r'(\d+\.?\d*)', value_str)
if not match:
continue
try:
numeric_value = float(match.group(1))
except (ValueError, AttributeError):
continue
name = item.name.strip()
if name not in indicator_map:
indicator_map[name] = []
indicator_map[name].append({
"week": week,
"value": numeric_value,
"unit": item.unit or "",
"reference_range": item.reference_range or "",
"status": item.status or "normal",
"date": date_str,
"report_type": r.report_type,
})
# 保留所有有数据的指标(含仅1次的,用于综合分析)
indicators = []
for name, points in indicator_map.items():
# 解析参考范围(取第一个非空的)
ref_lower = None
ref_upper = None
for p in points:
if p["reference_range"]:
# 尝试解析 "3.5-5.0" 或 "3.5~5.0" 格式
ref_match = re.search(r'(\d+\.?\d*)\s*[-~]\s*(\d+\.?\d*)', p["reference_range"])
if ref_match:
ref_lower = float(ref_match.group(1))
ref_upper = float(ref_match.group(2))
break
indicators.append({
"name": name,
"unit": points[0]["unit"],
"ref_lower": ref_lower,
"ref_upper": ref_upper,
"points": [{"week": p["week"], "value": p["value"], "date": p["date"],
"status": p["status"], "report_type": p["report_type"]} for p in points],
})
return {"indicators": indicators}
@router.get("/report-type-trends")
async def get_report_type_trends(user: User = Depends(get_current_user)):
"""按报告类型分组的指标趋势(同类型报告中出现2+次的数值指标)"""
reports = await Report.find(
Report.user_id == user.id,
).sort("pregnancy_week", "created_at").to_list()
# report_type -> indicator_name -> list of data points
type_map: dict[str, dict[str, list[dict]]] = {}
for r in reports:
rt = r.report_type or "其他"
week = r.pregnancy_week or 0
date_str = r.created_at.isoformat() if r.created_at else ""
if rt not in type_map:
type_map[rt] = {}
for item in r.data_items:
numeric_value = _parse_numeric_value(item.value)
if numeric_value is None:
continue
name = item.name.strip()
if name not in type_map[rt]:
type_map[rt][name] = []
type_map[rt][name].append({
"week": week,
"value": numeric_value,
"unit": item.unit or "",
"reference_range": item.reference_range or "",
"status": item.status or "normal",
"date": date_str,
})
# 组装结果:保留所有有数据的指标(含仅1个数据点的)
result = []
for rt, ind_map in type_map.items():
indicators = []
for name, points in ind_map.items():
ref_lower, ref_upper = None, None
for p in points:
ref_lower, ref_upper = _parse_reference_range(p["reference_range"])
if ref_lower is not None:
break
indicators.append({
"name": name,
"unit": points[0]["unit"],
"ref_lower": ref_lower,
"ref_upper": ref_upper,
"points": [{"week": p["week"], "value": p["value"], "date": p["date"],
"status": p["status"]} for p in points],
})
if indicators:
# 按数据点数量降序,让最有数据的指标排前面
indicators.sort(key=lambda x: len(x["points"]), reverse=True)
result.append({
"report_type": rt,
"report_count": sum(1 for r in reports if (r.report_type or "其他") == rt),
"indicators": indicators,
})
# 按报告份数降序
result.sort(key=lambda x: x["report_count"], reverse=True)
return {"report_types": result}

View File

@@ -0,0 +1,290 @@
import logging
from datetime import datetime, timezone, date
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from app.middleware.auth import get_current_user
from app.models.report import Report, DataItem
from app.models.user import User
from app.schemas.report import (
ReportVerifyRequest,
ReportResponse,
ReportListItem,
DataItemSchema,
)
from app.services.storage import save_upload_file
from app.services.ocr import ocr_recognize
from app.utils.helpers import parse_object_id, mask_name
logger = logging.getLogger(__name__)
router = APIRouter()
ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
@router.post("/upload")
async def upload_report_image(
file: UploadFile = File(...),
user: User = Depends(get_current_user),
):
"""上传报告图片并进行 OCR 识别"""
# Validate file type
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=400,
detail=f"不支持的文件类型: {file.content_type},请上传 JPG/PNG/WebP 图片",
)
# Validate file size (read content and check)
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(status_code=400, detail="文件大小不能超过 10MB")
# Reset file position for storage
await file.seek(0)
url = await save_upload_file(file, sub_dir="reports")
# OCR recognition
try:
ocr_result = await ocr_recognize(url)
except FileNotFoundError:
raise HTTPException(status_code=500, detail="文件保存异常,请重试")
except RuntimeError as e:
# OCR 服务已捕获并转换为友好消息
logger.warning("OCR 识别失败: %s", e)
raise HTTPException(status_code=503, detail=str(e))
except Exception as e:
logger.exception("OCR 识别失败")
raise HTTPException(
status_code=500,
detail="AI识别服务暂时不可用,请稍后重试",
)
# 检查是否为孕期相关报告
if not ocr_result.get("is_pregnancy_report", True):
reject_reason = ocr_result.get("reject_reason", "")
raise HTTPException(
status_code=422,
detail=f"请上传孕期检查报告。{reject_reason}",
)
# 根据报告日期 + 末次经期推算检查时的孕周
report_date_str = ocr_result.get("report_date")
pregnancy_week_at_report = user.pregnancy_week # 默认用用户当前孕周
if report_date_str and user.last_period_date:
try:
report_dt = date.fromisoformat(report_date_str)
lmp = date.fromisoformat(user.last_period_date) if isinstance(user.last_period_date, str) else user.last_period_date
delta_days = (report_dt - lmp).days
if 0 < delta_days < 300:
pregnancy_week_at_report = delta_days // 7
except (ValueError, TypeError):
pass
# Create report with OCR data
data_items = [DataItem(**item) for item in ocr_result.get("data_items", [])]
report = Report(
user_id=user.id,
report_type=ocr_result.get("report_type", ""),
pregnancy_week=pregnancy_week_at_report,
report_date=report_date_str,
image_urls=[url],
data_items=data_items,
patient_name=ocr_result.get("patient_name"),
status="pending",
)
await report.insert()
return {
"report_id": str(report.id),
"image_url": url,
"ocr_result": {
"report_type": report.report_type,
"data_items": [item.model_dump() for item in data_items],
"patient_name": mask_name(report.patient_name),
},
}
@router.post("/{report_id}/add-image")
async def add_image_to_report(
report_id: str,
file: UploadFile = File(...),
user: User = Depends(get_current_user),
):
"""向已有报告追加图片并进行 OCR 识别,合并数据"""
oid = parse_object_id(report_id)
report = await Report.get(oid)
if not report or report.user_id != user.id:
raise HTTPException(status_code=404, detail="报告不存在")
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=400,
detail=f"不支持的文件类型: {file.content_type},请上传 JPG/PNG/WebP 图片",
)
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(status_code=400, detail="文件大小不能超过 10MB")
await file.seek(0)
url = await save_upload_file(file, sub_dir="reports")
# OCR recognition
try:
ocr_result = await ocr_recognize(url)
except FileNotFoundError:
raise HTTPException(status_code=500, detail="文件保存异常,请重试")
except RuntimeError as e:
logger.warning("OCR 识别失败: %s", e)
raise HTTPException(status_code=503, detail=str(e))
except Exception:
logger.exception("OCR 识别失败")
raise HTTPException(status_code=500, detail="AI识别服务暂时不可用,请稍后重试")
# 检查是否为孕期相关报告
if not ocr_result.get("is_pregnancy_report", True):
reject_reason = ocr_result.get("reject_reason", "")
raise HTTPException(
status_code=422,
detail=f"请上传孕期检查报告。{reject_reason}",
)
# Merge new data into existing report
new_items = [DataItem(**item) for item in ocr_result.get("data_items", [])]
report.image_urls.append(url)
report.data_items.extend(new_items)
# Update report_type if current report has multiple types
new_type = ocr_result.get("report_type", "")
if new_type and new_type != report.report_type:
report.report_type = f"{report.report_type}+{new_type}" if report.report_type else new_type
report.updated_at = datetime.now(timezone.utc)
await report.save()
return {
"report_id": str(report.id),
"image_url": url,
"image_count": len(report.image_urls),
"ocr_result": {
"report_type": new_type,
"data_items": [item.model_dump() for item in new_items],
"patient_name": mask_name(ocr_result.get("patient_name")),
},
"total_items": len(report.data_items),
}
@router.put("/{report_id}/verify", response_model=ReportResponse)
async def verify_report(
report_id: str,
req: ReportVerifyRequest,
user: User = Depends(get_current_user),
):
"""数据校对确认"""
oid = parse_object_id(report_id)
report = await Report.get(oid)
if not report or report.user_id != user.id:
raise HTTPException(status_code=404, detail="报告不存在")
report.data_items = [DataItem(**item.model_dump()) for item in req.data_items]
report.symptoms = req.symptoms
report.notes = req.notes
if req.report_type:
report.report_type = req.report_type
report.status = "verified"
# Calculate overall status
statuses = [item.status for item in report.data_items]
if any(s in ("high", "low") for s in statuses):
abnormal_count = sum(1 for s in statuses if s != "normal")
report.overall_status = "warning" if abnormal_count >= 3 else "attention"
else:
report.overall_status = "normal"
report.updated_at = datetime.now(timezone.utc)
await report.save()
return _report_to_response(report)
@router.get("/list", response_model=list[ReportListItem])
async def list_reports(
limit: int = 50,
user: User = Depends(get_current_user),
):
"""获取报告列表"""
reports = await Report.find(
Report.user_id == user.id
).sort("-created_at").limit(limit).to_list()
return [
ReportListItem(
id=str(r.id),
report_type=r.report_type,
pregnancy_week=r.pregnancy_week,
overall_status=r.overall_status,
summary=_generate_summary(r),
item_count=len(r.data_items),
created_at=r.created_at,
)
for r in reports
]
@router.get("/{report_id}", response_model=ReportResponse)
async def get_report(report_id: str, user: User = Depends(get_current_user)):
"""获取报告详情"""
oid = parse_object_id(report_id)
report = await Report.get(oid)
if not report or report.user_id != user.id:
raise HTTPException(status_code=404, detail="报告不存在")
return _report_to_response(report)
def _generate_summary(report: Report) -> str:
"""从报告指标中生成有意义的摘要文本"""
if not report.data_items:
return "暂无指标数据"
# 优先展示异常指标
abnormal = [
item for item in report.data_items if item.status in ("high", "low")
]
# 再补充正常的关键指标
normal = [item for item in report.data_items if item.status == "normal"]
parts = []
# 先添加异常指标(最多2个)
for item in abnormal[:2]:
flag = "↑偏高" if item.status == "high" else "↓偏低"
val = f"{item.name}{item.value}{item.unit}({flag})" if item.unit else f"{item.name}{item.value}({flag})"
parts.append(val)
# 再补充正常指标,总共最多3个
for item in normal[:3 - len(parts)]:
val = f"{item.name}{item.value}{item.unit}" if item.unit else f"{item.name}{item.value}"
parts.append(val)
if not parts:
return f"共{len(report.data_items)}项指标"
return ",".join(parts)
def _report_to_response(report: Report) -> ReportResponse:
return ReportResponse(
id=str(report.id),
report_type=report.report_type,
pregnancy_week=report.pregnancy_week,
image_urls=report.image_urls,
data_items=[DataItemSchema(**item.model_dump()) for item in report.data_items],
symptoms=report.symptoms,
notes=report.notes,
status=report.status,
overall_status=report.overall_status,
created_at=report.created_at,
)

View File

@@ -0,0 +1,70 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.middleware.auth import get_current_user
from app.models.analysis import Analysis
from app.models.report import Report
from app.models.user import User
from app.utils.helpers import parse_object_id, mask_name
router = APIRouter()
class ShareOptions(BaseModel):
privacy_mode: bool = True # 隐私模式,脱敏个人信息
include_details: bool = False # 是否包含详细数据
@router.post("/{report_id}/generate")
async def generate_share_data(
report_id: str,
options: ShareOptions = ShareOptions(privacy_mode=True, include_details=True),
user: User = Depends(get_current_user),
):
"""生成分享数据"""
oid = parse_object_id(report_id)
report = await Report.get(oid)
if not report or report.user_id != user.id:
raise HTTPException(status_code=404, detail="报告不存在")
analysis = await Analysis.find_one(Analysis.report_id == report.id)
if not analysis:
raise HTTPException(status_code=404, detail="暂无解读结果")
# Build share data
share_data = {
"report_type": report.report_type,
"pregnancy_week": report.pregnancy_week,
"overall_status": analysis.overall_status,
"risk_level": analysis.risk_level,
"overall_summary": analysis.overall_summary,
"emotional_summary": analysis.emotional_summary,
"action_items": [a.model_dump() for a in analysis.action_items],
"tips": analysis.tips,
"categories": [
{
"category": cat.category,
"status": cat.status,
"indicators": [
{
"name": ind.name,
"nickname": ind.nickname,
"value": ind.value,
"status": ind.status,
"what_is_it": ind.what_is_it,
"impact": ind.impact,
"suggestion": ind.suggestion,
}
for ind in cat.indicators
],
}
for cat in analysis.categories
],
}
if not options.privacy_mode:
share_data["user_name"] = user.name
else:
share_data["user_name"] = mask_name(user.name)
return {"share_data": share_data}

View File

@@ -0,0 +1,84 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from app.middleware.auth import get_current_user
from app.models.user import User
from app.schemas.user import (
OnboardingRequest,
UserProfileUpdate,
UserProfileResponse,
)
router = APIRouter()
def _user_to_response(user: User) -> UserProfileResponse:
return UserProfileResponse(
id=str(user.id),
name=user.name,
phone=user.phone,
age=user.age,
height=user.height,
pre_pregnancy_weight=user.pre_pregnancy_weight,
current_weight=user.current_weight,
blood_type=user.blood_type,
allergies=user.allergies,
medical_history=user.medical_history,
last_period_date=user.last_period_date,
due_date=user.due_date,
pregnancy_week=user.pregnancy_week,
pregnancy_day=user.pregnancy_day,
multiple_pregnancy=user.multiple_pregnancy,
pregnancy_count=user.pregnancy_count,
emergency_contact=user.emergency_contact.model_dump() if user.emergency_contact else None,
is_onboarded=user.is_onboarded,
avatar_url=user.avatar_url,
created_at=user.created_at,
)
@router.get("/profile", response_model=UserProfileResponse)
async def get_profile(user: User = Depends(get_current_user)):
"""获取用户信息"""
return _user_to_response(user)
@router.post("/onboarding", response_model=UserProfileResponse)
async def complete_onboarding(
req: OnboardingRequest, user: User = Depends(get_current_user)
):
"""完成用户画像设置"""
user.name = req.name or user.name
user.age = req.age
user.height = req.height
user.pre_pregnancy_weight = req.pre_pregnancy_weight
user.current_weight = req.current_weight
user.blood_type = req.blood_type
user.allergies = req.allergies
user.medical_history = req.medical_history
user.last_period_date = req.last_period_date
user.due_date = req.due_date
user.pregnancy_week = req.pregnancy_week
user.pregnancy_day = req.pregnancy_day
user.multiple_pregnancy = req.multiple_pregnancy
user.pregnancy_count = req.pregnancy_count
if req.emergency_contact:
user.emergency_contact = req.emergency_contact
user.is_onboarded = True
user.updated_at = datetime.now(timezone.utc)
await user.save()
return _user_to_response(user)
@router.put("/profile", response_model=UserProfileResponse)
async def update_profile(
req: UserProfileUpdate, user: User = Depends(get_current_user)
):
"""更新用户信息"""
update_data = req.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(user, field, value)
user.updated_at = datetime.now(timezone.utc)
await user.save()
return _user_to_response(user)

View File

@@ -0,0 +1,20 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class WechatLoginRequest(BaseModel):
code: str # wx.login() 获取的 code
class PhoneLoginRequest(BaseModel):
phone: str
sms_code: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user_id: str
is_new_user: bool = False

View File

@@ -0,0 +1,21 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class ChatMessageRequest(BaseModel):
content: str
class ChatMessageResponse(BaseModel):
role: str
content: str
timestamp: datetime
class ChatSessionResponse(BaseModel):
id: str
report_id: str
messages: list[ChatMessageResponse]
created_at: datetime

View File

@@ -0,0 +1,44 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class DataItemSchema(BaseModel):
name: str
value: str
unit: str = ""
reference_range: str = ""
status: str = "normal"
category: str = ""
class ReportVerifyRequest(BaseModel):
"""数据校对提交"""
data_items: list[DataItemSchema]
symptoms: Optional[list[str]] = None
notes: Optional[str] = None
report_type: Optional[str] = None
class ReportResponse(BaseModel):
id: str
report_type: str
pregnancy_week: Optional[int]
image_urls: list[str]
data_items: list[DataItemSchema]
symptoms: Optional[list[str]]
notes: Optional[str]
status: str
overall_status: str
created_at: datetime
class ReportListItem(BaseModel):
id: str
report_type: str
pregnancy_week: Optional[int]
overall_status: str
summary: str = ""
item_count: int = 0
created_at: datetime

View File

@@ -0,0 +1,70 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class EmergencyContactSchema(BaseModel):
name: str = ""
relation: str = ""
phone: str = ""
class UserProfileUpdate(BaseModel):
name: Optional[str] = None
age: Optional[int] = None
phone: Optional[str] = None
height: Optional[float] = None
pre_pregnancy_weight: Optional[float] = None
current_weight: Optional[float] = None
blood_type: Optional[str] = None
allergies: Optional[list[str]] = None
medical_history: Optional[list[str]] = None
last_period_date: Optional[datetime] = None
due_date: Optional[datetime] = None
pregnancy_week: Optional[int] = None
pregnancy_day: Optional[int] = None
multiple_pregnancy: Optional[str] = None
pregnancy_count: Optional[int] = None
emergency_contact: Optional[EmergencyContactSchema] = None
class OnboardingRequest(BaseModel):
name: Optional[str] = ""
age: Optional[int] = None
height: Optional[float] = None
pre_pregnancy_weight: Optional[float] = None
current_weight: Optional[float] = None
blood_type: Optional[str] = None
allergies: list[str] = []
medical_history: list[str] = []
last_period_date: Optional[datetime] = None
due_date: Optional[datetime] = None
pregnancy_week: Optional[int] = None
pregnancy_day: Optional[int] = None
multiple_pregnancy: Optional[str] = None
pregnancy_count: Optional[int] = None
emergency_contact: Optional[EmergencyContactSchema] = None
class UserProfileResponse(BaseModel):
id: str
name: str
phone: Optional[str]
age: Optional[int]
height: Optional[float]
pre_pregnancy_weight: Optional[float]
current_weight: Optional[float]
blood_type: Optional[str]
allergies: list[str]
medical_history: list[str]
last_period_date: Optional[datetime]
due_date: Optional[datetime]
pregnancy_week: Optional[int]
pregnancy_day: Optional[int]
multiple_pregnancy: Optional[str]
pregnancy_count: Optional[int]
emergency_contact: Optional[EmergencyContactSchema]
is_onboarded: bool
avatar_url: Optional[str]
created_at: datetime

View File

@@ -0,0 +1,239 @@
"""
AI service: Report analysis, chat, doctor questions.
Uses OpenAI-compatible API (e.g. SiliconFlow).
"""
import json
import logging
from typing import Optional
from openai import AsyncOpenAI
from app.config import settings
from app.models.report import Report
from app.models.user import User
logger = logging.getLogger(__name__)
def _get_client() -> AsyncOpenAI:
return AsyncOpenAI(api_key=settings.ai_api_key, base_url=settings.ai_base_url)
def _build_report_context(report: Report, user: User) -> str:
"""构建报告上下文给 AI"""
lines = [
f"孕妈信息: 年龄{user.age}岁",
]
if report.pregnancy_week:
lines.append(f"检查时孕周: 第{report.pregnancy_week}周")
if report.report_date:
lines.append(f"报告日期: {report.report_date}")
if user.last_period_date:
lines.append(f"末次月经: {user.last_period_date}")
if user.height:
lines.append(f"身高: {user.height}cm")
if user.current_weight:
lines.append(f"当前体重: {user.current_weight}kg")
if user.blood_type:
lines.append(f"血型: {user.blood_type}")
if user.medical_history:
lines.append(f"病史: {', '.join(user.medical_history)}")
lines.append(f"\n报告类型: {report.report_type}")
lines.append("\n检查指标:")
for item in report.data_items:
ref = f", 参考范围: {item.reference_range}" if item.reference_range else ""
lines.append(f" - {item.name}: {item.value} {item.unit}{ref}")
if report.symptoms:
lines.append(f"\n自觉症状: {', '.join(report.symptoms)}")
return "\n".join(lines)
def _parse_json_response(text: str) -> dict:
"""从 AI 回复中提取 JSON"""
if "```json" in text:
text = text.split("```json")[1].split("```")[0]
elif "```" in text:
text = text.split("```")[1].split("```")[0]
return json.loads(text.strip())
async def _chat_complete(system: str, user_content: str, messages: Optional[list] = None, max_tokens: int = 4096) -> str:
"""统一调用 AI 文本模型"""
client = _get_client()
msgs = [{"role": "system", "content": system}]
if messages:
msgs.extend(messages)
else:
msgs.append({"role": "user", "content": user_content})
try:
response = await client.chat.completions.create(
model=settings.ai_text_model,
max_tokens=max_tokens,
temperature=0.7, # 降低温度提高生成速度
messages=msgs,
)
except Exception as e:
err_msg = str(e)
logger.error("AI 调用失败: %s: %s", type(e).__name__, err_msg[:200])
if "auth" in err_msg.lower() or "api key" in err_msg.lower():
raise RuntimeError("AI 服务认证失败,请检查 API Key") from e
raise RuntimeError("AI 服务暂时不可用,请稍后重试") from e
return response.choices[0].message.content
# ─── Report Analysis ───
ANALYSIS_SYSTEM_PROMPT = """你是一位专业、温暖的孕期健康顾问AI。你的任务是解读孕期检查报告,用通俗易懂的语言帮助孕妈理解检查结果。
核心要求:
1. 必须结合孕周来判断指标是否正常(孕期不同阶段的正常范围不同,如血红蛋白在孕中晚期会生理性下降)
2. 将每个指标的测定结果与报告中的参考范围对比,准确判断偏高/偏低/正常
3. 如果参考范围缺失,根据你的医学知识和该孕周的标准范围来判断
4. 用温暖、关怀的语气,避免医学术语恐吓
5. 对每个指标给出白话化的昵称(如血红蛋白→"氧气快递员",双顶径→"宝宝头围",腹围→"宝宝肚围",股骨长→"宝宝腿长")
6. 解释每个指标的含义、对宝宝的影响、行动建议
7. 给出整体评估和风险等级
8. 行动建议按优先级排序
**特别注意:**
- 如果是B超报告,重点关注胎儿生长指标(双顶径、腹围、股骨长),即使只有这3个指标也要给出完整的解读
- B超报告的解读要重点说明宝宝的生长发育情况,是否符合孕周标准
- 即使指标数量很少(如只有3个),也要给出详细的解读和建议
请严格按以下JSON格式返回:
{
"overall_status": "normal/attention/warning",
"risk_level": "low/medium/high",
"overall_summary": "整体评估文案",
"emotional_summary": "情感化总结,如'宝宝发育良好,继续保持'",
"categories": [
{
"category": "分类名称",
"status": "normal/attention/warning",
"indicators": [
{
"name": "指标名",
"nickname": "白话昵称",
"value": "值",
"status": "normal/high/low",
"what_is_it": "通俗解释",
"impact": "对宝宝的影响",
"suggestion": "行动建议",
"recheck_reminder": "复查提醒(可选)"
}
]
}
],
"action_items": [
{
"content": "具体建议",
"priority": "high/medium/low",
"category": "饮食/运动/就医/复查"
}
],
"tips": ["温馨提示1", "温馨提示2"]
}"""
async def analyze_report(report: Report, user: User) -> dict:
"""调用 AI 解读报告"""
logger.info("开始AI解读报告 report_id=%s", report.id)
context = _build_report_context(report, user)
response_text = await _chat_complete(
system=ANALYSIS_SYSTEM_PROMPT,
user_content=f"请解读以下孕期检查报告:\n\n{context}",
)
try:
return _parse_json_response(response_text)
except (json.JSONDecodeError, IndexError):
return {
"overall_status": "normal",
"risk_level": "low",
"overall_summary": response_text,
"emotional_summary": "报告解读完成",
"categories": [],
"action_items": [],
"tips": [],
}
# ─── Chat ───
CHAT_SYSTEM_PROMPT = """你是一位专业、温暖的孕期健康顾问AI。孕妈正在就她的检查报告向你提问。
要求:
1. 用通俗易懂、温暖关怀的语气回答
2. 基于报告数据给出专业建议
3. 遇到严重问题建议及时就医
4. 回答简洁明了,避免过长
以下是孕妈的检查报告信息:
{report_context}"""
async def chat_with_ai(
report: Report, user: User, messages: list[dict], user_message: str
) -> str:
"""AI 对话追问"""
context = _build_report_context(report, user)
system = CHAT_SYSTEM_PROMPT.format(report_context=context)
conversation = [{"role": "system", "content": system}]
for msg in messages[-10:]:
conversation.append({"role": msg["role"], "content": msg["content"]})
conversation.append({"role": "user", "content": user_message})
client = _get_client()
try:
response = await client.chat.completions.create(
model=settings.ai_text_model,
max_tokens=1024,
messages=conversation,
)
except Exception as e:
logger.error("AI 对话失败: %s", e)
raise RuntimeError("AI 对话服务暂时不可用") from e
return response.choices[0].message.content
# ─── Doctor Questions ───
DOCTOR_QUESTIONS_SYSTEM_PROMPT = """你是一位孕期健康顾问AI。根据检查报告中的异常项,帮孕妈生成3-5条看医生时可以问的问题。
要求:
1. 问题要专业但容易理解
2. 按重要性排序
3. 给出为什么要问这个问题的原因
请严格按以下JSON格式返回:
{
"questions": [
{
"category": "分类",
"question": "问医生的话术",
"reason": "为什么要问这个",
"priority": "high/medium/low"
}
]
}"""
async def generate_doctor_questions(report: Report, user: User) -> dict:
"""生成问诊清单"""
context = _build_report_context(report, user)
response_text = await _chat_complete(
system=DOCTOR_QUESTIONS_SYSTEM_PROMPT,
user_content=f"请根据以下报告生成问诊清单:\n\n{context}",
max_tokens=1024,
)
try:
return _parse_json_response(response_text)
except (json.JSONDecodeError, IndexError):
return {"questions": []}

View File

@@ -0,0 +1,142 @@
"""
OCR service: Use vision model to extract medical report data from images.
Supports OpenAI-compatible APIs (e.g. SiliconFlow).
"""
import base64
import json
import logging
from pathlib import Path
import aiofiles
from openai import AsyncOpenAI
from app.config import settings
logger = logging.getLogger(__name__)
OCR_PROMPT = """请分析这张图片,首先判断它是否是孕期/产检相关的医疗检查报告。
**孕期相关报告包括但不限于:**
- 产检报告(B超/彩超、NT检查、四维彩超、大排畸等)
- 血液检查(血常规、血糖、血型、唐氏筛查、TORCH、甲功等)
- 尿液检查(尿常规等)
- 肝肾功能检查
- 凝血功能检查
- 心电图报告
- 白带常规
- 胎心监护报告
- 其他与孕期产检相关的检查报告
**不属于孕期报告的情况:**
- 非医疗报告(发票、收据、菜单、截图、照片等)
- 与孕期无关的医疗报告(如骨科、眼科、口腔科等非产检相关)
- 无法识别内容的模糊图片
**请严格按以下JSON格式返回(不要输出其他内容):**
如果**不是**孕期相关报告,返回:
{
"is_pregnancy_report": false,
"reject_reason": "简短说明为什么不是孕期报告(如:这是一张超市购物小票)"
}
如果**是**孕期相关报告,提取所有检查指标并返回:
{
"is_pregnancy_report": true,
"patient_name": "患者姓名(如有)",
"report_type": "报告类型(如血常规、尿常规、B超等)",
"report_date": "报告日期(如有,格式 YYYY-MM-DD)",
"data_items": [
{
"name": "测定项目",
"value": "测定结果(只提取数字,不要单位)",
"unit": "单位",
"reference_range": "参考范围(如有)"
}
]
}
**重要提示:**
1. 必须先判断是否为孕期报告,is_pregnancy_report 字段必填
2. value字段只填写纯数字,不要包含单位(如"23.5"而不是"23.5mm")
3. 单位单独放在unit字段中
4. 如果是B超报告,务必确保双顶径(BPD)、腹围(AC)、股骨长(FL)这三个指标被正确提取"""
def _get_client() -> AsyncOpenAI:
return AsyncOpenAI(api_key=settings.ai_api_key, base_url=settings.ai_base_url)
async def ocr_recognize(image_path: str) -> dict:
"""
Use vision model to extract medical report data from an image.
Returns structured OCR results.
"""
# Resolve path relative to upload_dir
if image_path.startswith("/uploads/"):
relative = image_path[len("/uploads/"):]
full_path = Path(settings.upload_dir) / relative
else:
full_path = Path(settings.upload_dir) / image_path.lstrip("/")
if not full_path.exists():
logger.error("OCR 文件不存在: %s", full_path)
raise FileNotFoundError(f"报告图片不存在: {image_path}")
# Async file read
async with aiofiles.open(full_path, "rb") as f:
raw = await f.read()
image_data = base64.standard_b64encode(raw).decode("utf-8")
# Determine media type
suffix = full_path.suffix.lower()
media_type_map = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
}
media_type = media_type_map.get(suffix, "image/jpeg")
client = _get_client()
try:
response = await client.chat.completions.create(
model=settings.ai_vision_model,
max_tokens=2048,
messages=[
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {
"url": f"data:{media_type};base64,{image_data}",
},
},
{
"type": "text",
"text": OCR_PROMPT,
},
],
}
],
)
except Exception as e:
err_msg = str(e)
logger.error("AI OCR 调用失败: %s: %s", type(e).__name__, err_msg[:200])
if "auth" in err_msg.lower() or "api key" in err_msg.lower():
raise RuntimeError("AI 服务认证失败,请检查 API Key 是否有效") from e
if "<html" in err_msg.lower() or "cloudflare" in err_msg.lower():
raise RuntimeError("AI 服务请求被拦截,请检查 API 配置") from e
raise RuntimeError("AI 识别服务暂时不可用,请稍后重试") from e
response_text = response.choices[0].message.content
try:
if "```json" in response_text:
response_text = response_text.split("```json")[1].split("```")[0]
elif "```" in response_text:
response_text = response_text.split("```")[1].split("```")[0]
return json.loads(response_text.strip())
except json.JSONDecodeError:
return {"raw_text": response_text, "data_items": []}

View File

@@ -0,0 +1,24 @@
import os
import uuid
from pathlib import Path
import aiofiles
from fastapi import UploadFile
from app.config import settings
async def save_upload_file(file: UploadFile, sub_dir: str = "reports") -> str:
"""Save uploaded file and return the relative URL path"""
upload_path = Path(settings.upload_dir) / sub_dir
upload_path.mkdir(parents=True, exist_ok=True)
ext = Path(file.filename).suffix if file.filename else ".jpg"
filename = f"{uuid.uuid4().hex}{ext}"
file_path = upload_path / filename
content = await file.read()
async with aiofiles.open(file_path, "wb") as f:
await f.write(content)
return f"/uploads/{sub_dir}/{filename}"

View File

@@ -0,0 +1,26 @@
import httpx
from app.config import settings
async def code_to_session(code: str) -> dict:
"""用小程序 code 换取 openid 和 session_key"""
url = "https://api.weixin.qq.com/sns/jscode2session"
params = {
"appid": settings.wechat_app_id,
"secret": settings.wechat_app_secret,
"js_code": code,
"grant_type": "authorization_code",
}
async with httpx.AsyncClient() as client:
resp = await client.get(url, params=params)
data = resp.json()
if "errcode" in data and data["errcode"] != 0:
raise Exception(f"微信登录失败: {data.get('errmsg', '未知错误')}")
return {
"openid": data["openid"],
"session_key": data["session_key"],
"unionid": data.get("unionid"),
}

View File

@@ -0,0 +1,617 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>孕育无忧 - 孕期报告智能解读</title>
<style>
:root{--pink:#e91e8c;--pink-light:#fce4f3;--pink-bg:#fff5fb;--green:#27ae60;--orange:#f39c12;--red:#e74c3c;--gray:#999;--border:#eee;--shadow:0 2px 12px rgba(0,0,0,.08);--radius:12px}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB",sans-serif;background:#f5f5f5;color:#333;max-width:480px;margin:0 auto;min-height:100vh;position:relative}
.loading-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(255,255,255,.85);display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:9999}
.spinner{width:40px;height:40px;border:4px solid var(--pink-light);border-top-color:var(--pink);border-radius:50%;animation:spin .8s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.loading-overlay p{margin-top:12px;color:var(--pink);font-size:14px}
/* Header */
.header{background:linear-gradient(135deg,var(--pink),#c2185b);color:#fff;padding:20px;text-align:center;position:sticky;top:0;z-index:100}
.header h1{font-size:20px;font-weight:600}
.header p{font-size:12px;opacity:.85;margin-top:4px}
/* Tab bar */
.tab-bar{display:flex;background:#fff;border-bottom:1px solid var(--border);position:sticky;top:72px;z-index:99}
.tab-bar button{flex:1;padding:12px 0;border:none;background:none;font-size:13px;color:var(--gray);cursor:pointer;border-bottom:2px solid transparent;transition:.2s}
.tab-bar button.active{color:var(--pink);border-bottom-color:var(--pink);font-weight:600}
/* Pages */
.page{display:none;padding:16px}
.page.active{display:block}
/* Cards */
.card{background:#fff;border-radius:var(--radius);padding:16px;margin-bottom:12px;box-shadow:var(--shadow)}
.card h3{font-size:15px;margin-bottom:8px;color:#333}
.card p{font-size:13px;color:#666;line-height:1.6}
/* Upload area */
.upload-area{border:2px dashed #ddd;border-radius:var(--radius);padding:40px 20px;text-align:center;cursor:pointer;transition:.2s;background:#fafafa}
.upload-area:hover{border-color:var(--pink);background:var(--pink-bg)}
.upload-area .icon{font-size:48px;margin-bottom:12px}
.upload-area p{color:var(--gray);font-size:14px}
.upload-area input{display:none}
/* Image preview */
.preview-img{max-width:100%;border-radius:8px;margin:12px 0}
/* Status badges */
.badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:12px;font-weight:500}
.badge-normal{background:#e8f5e9;color:var(--green)}
.badge-attention{background:#fff3e0;color:var(--orange)}
.badge-warning{background:#ffebee;color:var(--red)}
.badge-high{background:#ffebee;color:var(--red)}
.badge-low{background:#fff3e0;color:var(--orange)}
/* Buttons */
.btn{display:inline-block;padding:10px 24px;border-radius:24px;border:none;font-size:14px;cursor:pointer;transition:.2s;font-weight:500}
.btn-primary{background:var(--pink);color:#fff}
.btn-primary:hover{background:#c2185b}
.btn-primary:disabled{background:#ccc;cursor:not-allowed}
.btn-outline{border:1px solid var(--pink);color:var(--pink);background:none}
.btn-block{display:block;width:100%;text-align:center}
/* Data items table */
.data-table{width:100%;border-collapse:collapse;font-size:13px;margin:8px 0}
.data-table th{text-align:left;padding:8px 6px;border-bottom:2px solid var(--border);color:var(--gray);font-weight:500}
.data-table td{padding:8px 6px;border-bottom:1px solid var(--border)}
/* Chat */
.chat-container{display:flex;flex-direction:column;height:calc(100vh - 170px)}
.chat-messages{flex:1;overflow-y:auto;padding:12px 0}
.chat-msg{margin-bottom:12px;display:flex}
.chat-msg.user{justify-content:flex-end}
.chat-bubble{max-width:80%;padding:10px 14px;border-radius:16px;font-size:14px;line-height:1.6;white-space:pre-wrap}
.chat-msg.user .chat-bubble{background:var(--pink);color:#fff;border-bottom-right-radius:4px}
.chat-msg.assistant .chat-bubble{background:#fff;color:#333;border-bottom-left-radius:4px;box-shadow:var(--shadow)}
.chat-input-area{display:flex;gap:8px;padding:12px 0}
.chat-input-area input{flex:1;padding:10px 16px;border:1px solid #ddd;border-radius:24px;font-size:14px;outline:none}
.chat-input-area input:focus{border-color:var(--pink)}
.quick-questions{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px}
.quick-q{padding:6px 12px;background:var(--pink-bg);color:var(--pink);border-radius:16px;font-size:12px;cursor:pointer;border:none;transition:.2s}
.quick-q:hover{background:var(--pink-light)}
/* Report list */
.report-item{display:flex;align-items:center;padding:12px;border-bottom:1px solid var(--border);cursor:pointer;transition:.1s}
.report-item:hover{background:#fafafa}
.report-item .info{flex:1;margin-left:12px}
.report-item .info h4{font-size:14px;margin-bottom:4px}
.report-item .info p{font-size:12px;color:var(--gray)}
.report-icon{width:40px;height:40px;border-radius:10px;background:var(--pink-light);display:flex;align-items:center;justify-content:center;font-size:20px}
/* Analysis result */
.analysis-section{margin-bottom:16px}
.analysis-section h4{font-size:14px;margin-bottom:8px;color:var(--pink)}
.indicator-card{background:#fafafa;border-radius:8px;padding:12px;margin-bottom:8px}
.indicator-card .name{font-weight:600;font-size:14px}
.indicator-card .nickname{color:var(--pink);font-size:12px;margin-left:6px}
.indicator-card .detail{font-size:13px;color:#555;margin-top:6px;line-height:1.6}
/* Action items */
.action-item{display:flex;align-items:flex-start;gap:8px;padding:8px 0;border-bottom:1px solid var(--border)}
.action-dot{width:8px;height:8px;border-radius:50%;margin-top:5px;flex-shrink:0}
.action-dot.high{background:var(--red)}
.action-dot.medium{background:var(--orange)}
.action-dot.low{background:var(--green)}
/* Empty state */
.empty-state{text-align:center;padding:60px 20px;color:var(--gray)}
.empty-state .icon{font-size:64px;margin-bottom:16px}
/* Verify form */
.verify-item{display:flex;gap:8px;margin-bottom:8px;align-items:center;flex-wrap:wrap}
.verify-item input,.verify-item select{padding:6px 10px;border:1px solid #ddd;border-radius:6px;font-size:13px}
.verify-item input{width:80px}
.verify-item .item-name{font-size:13px;font-weight:500;min-width:100px}
textarea{width:100%;padding:10px;border:1px solid #ddd;border-radius:8px;font-size:13px;resize:vertical;min-height:60px}
</style>
</head>
<body>
<div class="loading-overlay" id="globalLoading" style="display:none">
<div class="spinner"></div>
<p id="loadingText">加载中...</p>
</div>
<div class="header">
<h1>孕育无忧</h1>
<p>AI 智能解读孕期检查报告</p>
</div>
<div class="tab-bar">
<button class="active" onclick="showPage('home')">首页</button>
<button onclick="showPage('reports')">报告</button>
<button onclick="showPage('chat')">AI问答</button>
<button onclick="showPage('me')">我的</button>
</div>
<!-- ===== 首页 ===== -->
<div class="page active" id="page-home">
<div class="card">
<h3>📸 上传检查报告</h3>
<p style="margin-bottom:12px">拍照或选择孕期检查报告图片,AI 自动识别并解读</p>
<div class="upload-area" id="uploadArea">
<div class="icon">📋</div>
<p>点击上传报告图片</p>
<p style="font-size:12px;margin-top:4px;color:#bbb">支持 JPG、PNG 格式</p>
<input type="file" id="fileInput" accept="image/*">
</div>
</div>
<div id="ocrResult" style="display:none">
<div class="card">
<h3>📝 OCR 识别结果</h3>
<p style="margin-bottom:8px;color:var(--gray);font-size:12px">请核对以下识别数据,如有误请修正后确认</p>
<img id="previewImg" class="preview-img" alt="报告图片">
<div id="ocrDataItems"></div>
<div style="margin-top:12px">
<label style="font-size:13px;font-weight:500">自觉症状(可选)</label>
<textarea id="symptomsInput" placeholder="如:头晕、水肿、腰痛等..." style="margin-top:6px"></textarea>
</div>
<div style="margin-top:12px;text-align:center">
<button class="btn btn-primary btn-block" onclick="verifyReport()">确认数据,生成 AI 解读</button>
</div>
</div>
</div>
<div id="analysisResult" style="display:none"></div>
</div>
<!-- ===== 报告列表 ===== -->
<div class="page" id="page-reports">
<div class="card">
<h3>📋 我的报告</h3>
<div id="reportList">
<div class="empty-state">
<div class="icon">📭</div>
<p>暂无报告,去首页上传吧</p>
</div>
</div>
</div>
</div>
<!-- ===== AI 对话 ===== -->
<div class="page" id="page-chat">
<div class="chat-container">
<div id="chatReportSelect" class="card" style="padding:10px 14px">
<label style="font-size:13px;color:var(--gray)">选择报告进行追问:</label>
<select id="chatReportId" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:8px;margin-top:6px;font-size:13px">
<option value="">请先上传报告</option>
</select>
</div>
<div id="quickQuestions" class="quick-questions"></div>
<div class="chat-messages" id="chatMessages">
<div class="empty-state">
<div class="icon">💬</div>
<p>选择报告后可以向 AI 追问</p>
</div>
</div>
<div class="chat-input-area">
<input type="text" id="chatInput" placeholder="输入你的问题..." onkeydown="if(event.key==='Enter')sendChat()">
<button class="btn btn-primary" onclick="sendChat()">发送</button>
</div>
</div>
</div>
<!-- ===== 我的 ===== -->
<div class="page" id="page-me">
<div class="card" style="text-align:center;padding:30px">
<div style="width:64px;height:64px;border-radius:50%;background:var(--pink-light);margin:0 auto 12px;display:flex;align-items:center;justify-content:center;font-size:28px">👩</div>
<h3 id="userName">体验用户</h3>
<p style="color:var(--gray);margin-top:4px" id="userInfo">加载中...</p>
</div>
<div class="card">
<h3>🍎 营养建议</h3>
<p style="margin-bottom:12px;font-size:13px;color:var(--gray)">根据报告选择对应的营养建议</p>
<select id="nutritionReportId" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:8px;font-size:13px">
<option value="">选择报告</option>
</select>
<button class="btn btn-outline btn-block" style="margin-top:8px" onclick="getNutrition()">获取营养建议</button>
<div id="nutritionResult" style="margin-top:12px"></div>
</div>
<div class="card">
<h3>🏃‍♀️ 运动建议</h3>
<button class="btn btn-outline btn-block" onclick="getExercise()">获取运动建议</button>
<div id="exerciseResult" style="margin-top:12px"></div>
</div>
</div>
<script>
const API = '';
let TOKEN = '';
let currentReportId = '';
let ocrDataItems = [];
// ---- Helpers ----
function showLoading(text='加载中...'){
document.getElementById('loadingText').textContent=text;
document.getElementById('globalLoading').style.display='flex';
}
function hideLoading(){document.getElementById('globalLoading').style.display='none'}
async function api(path,opts={}){
const headers = {'Authorization':'Bearer '+TOKEN, ...(opts.headers||{})};
if(opts.body && !(opts.body instanceof FormData)){
headers['Content-Type']='application/json';
opts.body=JSON.stringify(opts.body);
}
const res = await fetch(API+path, {...opts, headers});
if(!res.ok){
const err = await res.json().catch(()=>({detail:'请求失败'}));
throw new Error(err.detail||'请求失败');
}
return res.json();
}
function showPage(name){
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
document.getElementById('page-'+name).classList.add('active');
document.querySelectorAll('.tab-bar button').forEach((b,i)=>{
b.classList.toggle('active', ['home','reports','chat','me'][i]===name);
});
if(name==='reports') loadReportList();
if(name==='chat') loadChatReportSelect();
if(name==='me') loadProfile();
}
function badgeHtml(status){
const map={normal:'正常',attention:'注意',warning:'警告',high:'偏高',low:'偏低'};
return `<span class="badge badge-${status}">${map[status]||status}</span>`;
}
// ---- Auth ----
async function demoLogin(){
showLoading('正在登录...');
try{
const data = await fetch(API+'/api/auth/demo-login',{method:'POST'}).then(r=>r.json());
TOKEN = data.access_token;
hideLoading();
}catch(e){
hideLoading();
alert('登录失败:'+e.message);
}
}
// ---- Upload ----
document.getElementById('uploadArea').onclick=()=>document.getElementById('fileInput').click();
document.getElementById('fileInput').onchange=async function(){
const file = this.files[0];
if(!file) return;
showLoading('AI 正在识别报告...');
const fd = new FormData();
fd.append('file', file);
try{
const data = await api('/api/report/upload',{method:'POST',body:fd});
currentReportId = data.report_id;
ocrDataItems = data.ocr_result.data_items || [];
// Show preview
document.getElementById('previewImg').src = API + '/' + data.image_url;
renderOcrItems(ocrDataItems, data.ocr_result.report_type);
document.getElementById('ocrResult').style.display='block';
document.getElementById('analysisResult').style.display='none';
hideLoading();
}catch(e){
hideLoading();
alert('上传失败:'+e.message);
}
};
function renderOcrItems(items, reportType){
let html = `<div class="verify-item" style="margin-bottom:12px">
<span class="item-name">报告类型</span>
<input id="reportTypeInput" value="${reportType||''}" style="flex:1" placeholder="如:血常规">
</div>`;
html += '<table class="data-table"><thead><tr><th>指标</th><th>数值</th><th>单位</th><th>参考范围</th><th>状态</th></tr></thead><tbody>';
items.forEach((item,i)=>{
html += `<tr>
<td><input data-idx="${i}" data-field="name" value="${item.name||''}" style="width:80px;border:1px solid #eee;padding:4px;border-radius:4px"></td>
<td><input data-idx="${i}" data-field="value" value="${item.value||''}" style="width:60px;border:1px solid #eee;padding:4px;border-radius:4px"></td>
<td><input data-idx="${i}" data-field="unit" value="${item.unit||''}" style="width:50px;border:1px solid #eee;padding:4px;border-radius:4px"></td>
<td><input data-idx="${i}" data-field="reference_range" value="${item.reference_range||''}" style="width:80px;border:1px solid #eee;padding:4px;border-radius:4px"></td>
<td><select data-idx="${i}" data-field="status" style="border:1px solid #eee;padding:4px;border-radius:4px">
<option value="normal" ${item.status==='normal'?'selected':''}>正常</option>
<option value="high" ${item.status==='high'?'selected':''}>偏高</option>
<option value="low" ${item.status==='low'?'selected':''}>偏低</option>
</select></td>
</tr>`;
});
html += '</tbody></table>';
document.getElementById('ocrDataItems').innerHTML = html;
}
async function verifyReport(){
// Collect edited data
const items = ocrDataItems.map((_,i)=>{
const get = f => document.querySelector(`[data-idx="${i}"][data-field="${f}"]`).value;
return {name:get('name'),value:get('value'),unit:get('unit'),reference_range:get('reference_range'),status:get('status'),category:''};
});
const reportType = document.getElementById('reportTypeInput').value;
const symptoms = document.getElementById('symptomsInput').value;
showLoading('正在确认数据...');
try{
await api(`/api/report/${currentReportId}/verify`,{
method:'PUT',
body:{data_items:items,symptoms:symptoms||null,notes:null,report_type:reportType||null}
});
hideLoading();
generateAnalysis();
}catch(e){
hideLoading();
alert('确认失败:'+e.message);
}
}
async function generateAnalysis(){
showLoading('AI 正在解读报告,请稍候...');
try{
const data = await api(`/api/analysis/${currentReportId}/generate`,{method:'POST'});
renderAnalysis(data);
hideLoading();
}catch(e){
hideLoading();
alert('解读失败:'+e.message);
}
}
function renderAnalysis(data){
let html = '<div class="card"><h3>🤖 AI 解读报告</h3>';
// Overall
html += `<div class="analysis-section">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
${badgeHtml(data.overall_status)}
<span style="font-size:13px;color:var(--gray)">风险等级:${({low:'低',medium:'中',high:'高'})[data.risk_level]||data.risk_level}</span>
</div>
<p style="font-size:14px;line-height:1.6">${data.overall_summary}</p>
<p style="font-size:14px;color:var(--pink);margin-top:6px">${data.emotional_summary}</p>
</div>`;
// Categories
if(data.categories && data.categories.length){
data.categories.forEach(cat=>{
html += `<div class="analysis-section">
<h4>${cat.category} ${badgeHtml(cat.status)}</h4>`;
(cat.indicators||[]).forEach(ind=>{
html += `<div class="indicator-card">
<div><span class="name">${ind.name}</span><span class="nickname">${ind.nickname||''}</span> ${badgeHtml(ind.status)} <span style="font-size:12px;color:var(--gray);margin-left:6px">${ind.value||''}</span></div>
<div class="detail">
${ind.what_is_it?'<div>📖 '+ind.what_is_it+'</div>':''}
${ind.impact?'<div>👶 '+ind.impact+'</div>':''}
${ind.suggestion?'<div>💡 '+ind.suggestion+'</div>':''}
${ind.recheck_reminder?'<div>🔔 '+ind.recheck_reminder+'</div>':''}
</div>
</div>`;
});
html += '</div>';
});
}
// Action items
if(data.action_items && data.action_items.length){
html += '<div class="analysis-section"><h4>📋 行动建议</h4>';
data.action_items.forEach(a=>{
html += `<div class="action-item"><div class="action-dot ${a.priority}"></div><div>
<div style="font-size:13px">${a.content}</div>
<div style="font-size:11px;color:var(--gray)">${a.category||''}</div>
</div></div>`;
});
html += '</div>';
}
// Tips
if(data.tips && data.tips.length){
html += '<div class="analysis-section"><h4>💝 温馨提示</h4>';
data.tips.forEach(t=>{html += `<p style="font-size:13px;padding:4px 0;color:#555">• ${t}</p>`});
html += '</div>';
}
html += `<div style="margin-top:12px;display:flex;gap:8px">
<button class="btn btn-outline" style="flex:1" onclick="showPage('chat')">💬 追问 AI</button>
<button class="btn btn-outline" style="flex:1" onclick="getDoctorQuestions()">🩺 问诊清单</button>
</div>`;
html += '</div>';
document.getElementById('analysisResult').innerHTML = html;
document.getElementById('analysisResult').style.display = 'block';
}
// ---- Report List ----
async function loadReportList(){
try{
const data = await api('/api/report/list');
if(!data.length){
document.getElementById('reportList').innerHTML='<div class="empty-state"><div class="icon">📭</div><p>暂无报告,去首页上传吧</p></div>';
return;
}
let html='';
data.forEach(r=>{
const date = new Date(r.created_at).toLocaleDateString('zh-CN');
html += `<div class="report-item" onclick="viewReport('${r.id}')">
<div class="report-icon">📋</div>
<div class="info"><h4>${r.report_type||'检查报告'}</h4><p>${date} · 孕${r.pregnancy_week||'?'}周 · ${r.item_count}项指标</p></div>
${badgeHtml(r.overall_status)}
</div>`;
});
document.getElementById('reportList').innerHTML=html;
}catch(e){}
}
async function viewReport(id){
currentReportId = id;
showLoading('加载报告...');
try{
const data = await api(`/api/analysis/${id}`);
renderAnalysis(data);
showPage('home');
document.getElementById('ocrResult').style.display='none';
document.getElementById('analysisResult').style.display='block';
hideLoading();
}catch(e){
hideLoading();
// No analysis yet, show report detail
try{
const report = await api(`/api/report/${id}`);
currentReportId = id;
ocrDataItems = report.data_items||[];
renderOcrItems(ocrDataItems, report.report_type);
showPage('home');
document.getElementById('ocrResult').style.display='block';
document.getElementById('analysisResult').style.display='none';
}catch(e2){alert(e2.message)}
}
}
// ---- Chat ----
async function loadChatReportSelect(){
try{
const data = await api('/api/report/list');
const sel = document.getElementById('chatReportId');
sel.innerHTML = data.length ? data.map(r=>`<option value="${r.id}">${r.report_type||'报告'} - ${new Date(r.created_at).toLocaleDateString('zh-CN')}</option>`).join('') : '<option value="">请先上传报告</option>';
if(currentReportId) sel.value=currentReportId;
loadChatHistory();
}catch(e){}
}
async function loadChatHistory(){
const rid = document.getElementById('chatReportId').value;
if(!rid) return;
try{
const data = await api(`/api/chat/${rid}/history`);
const container = document.getElementById('chatMessages');
if(!data.messages.length){
container.innerHTML='<div class="empty-state" style="padding:30px"><div class="icon">💬</div><p>有任何疑问都可以问我哦~</p></div>';
}else{
container.innerHTML = data.messages.map(m=>`<div class="chat-msg ${m.role}"><div class="chat-bubble">${m.content}</div></div>`).join('');
container.scrollTop = container.scrollHeight;
}
// Quick questions
const qqs = data.quick_questions||["这个指标偏高会影响宝宝吗?","我需要注意什么饮食?","什么时候需要复查?"];
document.getElementById('quickQuestions').innerHTML = qqs.map(q=>`<button class="quick-q" onclick="askQuick('${q}')">${q}</button>`).join('');
}catch(e){}
}
document.getElementById('chatReportId').onchange=loadChatHistory;
function askQuick(q){document.getElementById('chatInput').value=q;sendChat()}
async function sendChat(){
const rid = document.getElementById('chatReportId').value;
const input = document.getElementById('chatInput');
const msg = input.value.trim();
if(!rid||!msg) return;
input.value='';
const container = document.getElementById('chatMessages');
// Clear empty state
if(container.querySelector('.empty-state')) container.innerHTML='';
container.innerHTML+=`<div class="chat-msg user"><div class="chat-bubble">${msg}</div></div>`;
container.innerHTML+=`<div class="chat-msg assistant" id="typingIndicator"><div class="chat-bubble">思考中...</div></div>`;
container.scrollTop = container.scrollHeight;
try{
const data = await api(`/api/chat/${rid}/message`,{method:'POST',body:{content:msg}});
document.getElementById('typingIndicator').remove();
container.innerHTML+=`<div class="chat-msg assistant"><div class="chat-bubble">${data.ai_message.content}</div></div>`;
container.scrollTop = container.scrollHeight;
}catch(e){
document.getElementById('typingIndicator').remove();
container.innerHTML+=`<div class="chat-msg assistant"><div class="chat-bubble" style="color:var(--red)">回复失败:${e.message}</div></div>`;
}
}
// ---- Profile ----
async function loadProfile(){
try{
const data = await api('/api/user/profile');
document.getElementById('userName').textContent = data.name||'体验用户';
document.getElementById('userInfo').textContent = `${data.age||'?'}岁 · 孕${data.pregnancy_week||'?'}周${data.pregnancy_day||0}天 · ${data.blood_type||'?'}型血`;
// Also update nutrition select
const reports = await api('/api/report/list');
document.getElementById('nutritionReportId').innerHTML = reports.length ? reports.map(r=>`<option value="${r.id}">${r.report_type||'报告'} - ${new Date(r.created_at).toLocaleDateString('zh-CN')}</option>`).join('') : '<option value="">暂无报告</option>';
}catch(e){}
}
// ---- Nutrition ----
async function getNutrition(){
const rid = document.getElementById('nutritionReportId').value;
if(!rid){alert('请先选择报告');return}
showLoading('AI 正在生成营养建议...');
try{
const data = await api(`/api/nutrition/${rid}`);
let html='';
(data.recipes||[]).forEach(r=>{
html+=`<div style="background:#fafafa;border-radius:8px;padding:12px;margin-bottom:8px">
<div style="font-weight:600;font-size:14px">🍳 ${r.name}</div>
<p style="font-size:12px;color:var(--gray);margin-top:4px">${r.description||''}</p>
<p style="font-size:12px;margin-top:4px">⏱ ${r.time||''} · ${r.difficulty||''}</p>
<p style="font-size:12px;color:var(--pink);margin-top:4px">📌 ${r.reason||''}</p>
${r.ingredients?'<p style="font-size:12px;margin-top:4px">食材:'+r.ingredients.join('、')+'</p>':''}
</div>`;
});
if(data.dietary_tips&&data.dietary_tips.length){
html+='<div style="margin-top:8px"><h4 style="font-size:13px;color:var(--orange)">⚠️ 饮食提醒</h4>';
data.dietary_tips.forEach(t=>{html+=`<p style="font-size:13px;padding:2px 0">• ${t}</p>`});
html+='</div>';
}
document.getElementById('nutritionResult').innerHTML=html;
hideLoading();
}catch(e){hideLoading();alert('获取失败:'+e.message)}
}
// ---- Exercise ----
async function getExercise(){
showLoading('AI 正在生成运动建议...');
try{
const data = await api('/api/exercise/recommendations');
let html='';
(data.exercises||[]).forEach(ex=>{
html+=`<div style="background:#fafafa;border-radius:8px;padding:12px;margin-bottom:8px">
<div style="font-weight:600;font-size:14px">🏃 ${ex.name}</div>
<p style="font-size:12px;color:var(--gray);margin-top:4px">${ex.description||''}</p>
<p style="font-size:12px;margin-top:4px">⏱ ${ex.duration||''} · 🔥 ${ex.calories||''} · ${ex.difficulty||''}</p>
${ex.benefits?'<p style="font-size:12px;color:var(--green);margin-top:4px">✅ '+ex.benefits.join(' · ')+'</p>':''}
${ex.precautions?'<p style="font-size:12px;color:var(--orange);margin-top:4px">⚠️ '+ex.precautions.join(' · ')+'</p>':''}
</div>`;
});
if(data.daily_schedule&&data.daily_schedule.length){
html+='<div style="margin-top:8px"><h4 style="font-size:13px;color:var(--pink)">📅 每日运动安排</h4>';
data.daily_schedule.forEach(s=>{html+=`<p style="font-size:13px;padding:2px 0">${s.time} ${s.activity} (${s.duration})</p>`});
html+='</div>';
}
document.getElementById('exerciseResult').innerHTML=html;
hideLoading();
}catch(e){hideLoading();alert('获取失败:'+e.message)}
}
// ---- Doctor Questions ----
async function getDoctorQuestions(){
if(!currentReportId){alert('请先选择报告');return}
showLoading('AI 正在生成问诊清单...');
try{
const data = await api(`/api/doctor/questions/${currentReportId}`);
let html='<div class="card"><h3>🩺 问诊清单</h3><p style="font-size:12px;color:var(--gray);margin-bottom:12px">带着这些问题去看医生,沟通更高效</p>';
(data.questions||[]).forEach(q=>{
html+=`<div class="action-item"><div class="action-dot ${q.priority}"></div><div>
<div style="font-size:14px;font-weight:500">"${q.question}"</div>
<div style="font-size:12px;color:var(--gray);margin-top:2px">💡 ${q.reason}</div>
</div></div>`;
});
html+='</div>';
document.getElementById('analysisResult').innerHTML+=html;
hideLoading();
}catch(e){hideLoading();alert('获取失败:'+e.message)}
}
// ---- Init ----
demoLogin();
</script>
</body>
</html>

View File

@@ -0,0 +1,19 @@
from typing import Optional
from beanie import PydanticObjectId
from fastapi import HTTPException
def parse_object_id(value: str) -> PydanticObjectId:
"""Parse a string to PydanticObjectId, raising 400 on invalid format."""
try:
return PydanticObjectId(value)
except Exception:
raise HTTPException(status_code=400, detail="无效的ID格式")
def mask_name(name: Optional[str]) -> str:
"""隐私脱敏:姓名"""
if not name or len(name) < 2:
return name or ""
return name[0] + "*" * (len(name) - 1)

View File

@@ -0,0 +1,32 @@
# FastAPI
fastapi==0.115.6
uvicorn[standard]==0.34.0
python-multipart==0.0.19
# Database
motor==3.6.0
beanie==1.27.0
# Authentication
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
# HTTP Client
httpx==0.28.1
# AI
anthropic==0.42.0
openai==1.58.1
# Storage / File
python-magic==0.4.27
Pillow==11.1.0
aiofiles==24.1.0
# Utils
pydantic[email]==2.10.4
pydantic-settings==2.7.1
python-dotenv==1.0.1
# CORS
# (included in fastapi)

View File

@@ -0,0 +1,10 @@
import uvicorn
from app.config import settings
if __name__ == "__main__":
uvicorn.run(
"app.main:app",
host=settings.host,
port=settings.port,
reload=settings.debug,
)

View File

@@ -0,0 +1,47 @@
"""
清理旧的共享 demo_web_user 及其关联数据。
部署新版 demo 登录逻辑后运行一次即可:
python -m scripts.cleanup_demo_users
"""
import asyncio
from motor.motor_asyncio import AsyncIOMotorClient
from beanie import init_beanie
from app.config import Settings
from app.models.user import User
from app.models.report import Report
from app.models.analysis import Analysis
from app.models.chat import ChatSession
from app.models.checkup import CheckupPlan
async def main():
settings = Settings()
client = AsyncIOMotorClient(settings.mongodb_url)
db = client[settings.database_name]
await init_beanie(database=db, document_models=[User, Report, Analysis, ChatSession, CheckupPlan])
user = await User.find_one(User.openid == "demo_web_user")
if not user:
print("未找到 demo_web_user,无需清理。")
return
uid = user.id
r = await Report.find(Report.user_id == uid).delete()
a = await Analysis.find(Analysis.user_id == uid).delete()
c = await ChatSession.find(ChatSession.user_id == uid).delete()
p = await CheckupPlan.find(CheckupPlan.user_id == uid).delete()
await user.delete()
print(f"已清理 demo_web_user (id={uid}):")
print(f" Reports: {r.deleted_count if r else 0}")
print(f" Analyses: {a.deleted_count if a else 0}")
print(f" ChatSessions: {c.deleted_count if c else 0}")
print(f" CheckupPlans: {p.deleted_count if p else 0}")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,247 @@
<#
.Synopsis
Activate a Python virtual environment for the current PowerShell session.
.Description
Pushes the python executable for a virtual environment to the front of the
$Env:PATH environment variable and sets the prompt to signify that you are
in a Python virtual environment. Makes use of the command line switches as
well as the `pyvenv.cfg` file values present in the virtual environment.
.Parameter VenvDir
Path to the directory that contains the virtual environment to activate. The
default value for this is the parent of the directory that the Activate.ps1
script is located within.
.Parameter Prompt
The prompt prefix to display when this virtual environment is activated. By
default, this prompt is the name of the virtual environment folder (VenvDir)
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
.Example
Activate.ps1
Activates the Python virtual environment that contains the Activate.ps1 script.
.Example
Activate.ps1 -Verbose
Activates the Python virtual environment that contains the Activate.ps1 script,
and shows extra information about the activation as it executes.
.Example
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
Activates the Python virtual environment located in the specified location.
.Example
Activate.ps1 -Prompt "MyPython"
Activates the Python virtual environment that contains the Activate.ps1 script,
and prefixes the current prompt with the specified string (surrounded in
parentheses) while the virtual environment is active.
.Notes
On Windows, it may be required to enable this Activate.ps1 script by setting the
execution policy for the user. You can do this by issuing the following PowerShell
command:
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
For more information on Execution Policies:
https://go.microsoft.com/fwlink/?LinkID=135170
#>
Param(
[Parameter(Mandatory = $false)]
[String]
$VenvDir,
[Parameter(Mandatory = $false)]
[String]
$Prompt
)
<# Function declarations --------------------------------------------------- #>
<#
.Synopsis
Remove all shell session elements added by the Activate script, including the
addition of the virtual environment's Python executable from the beginning of
the PATH variable.
.Parameter NonDestructive
If present, do not remove this function from the global namespace for the
session.
#>
function global:deactivate ([switch]$NonDestructive) {
# Revert to original values
# The prior prompt:
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
}
# The prior PYTHONHOME:
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
}
# The prior PATH:
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
}
# Just remove the VIRTUAL_ENV altogether:
if (Test-Path -Path Env:VIRTUAL_ENV) {
Remove-Item -Path env:VIRTUAL_ENV
}
# Just remove VIRTUAL_ENV_PROMPT altogether.
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
}
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
}
# Leave deactivate function in the global namespace if requested:
if (-not $NonDestructive) {
Remove-Item -Path function:deactivate
}
}
<#
.Description
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
given folder, and returns them in a map.
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
two strings separated by `=` (with any amount of whitespace surrounding the =)
then it is considered a `key = value` line. The left hand string is the key,
the right hand is the value.
If the value starts with a `'` or a `"` then the first and last character is
stripped from the value before being captured.
.Parameter ConfigDir
Path to the directory that contains the `pyvenv.cfg` file.
#>
function Get-PyVenvConfig(
[String]
$ConfigDir
) {
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
# An empty map will be returned if no config file is found.
$pyvenvConfig = @{ }
if ($pyvenvConfigPath) {
Write-Verbose "File exists, parse `key = value` lines"
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
$pyvenvConfigContent | ForEach-Object {
$keyval = $PSItem -split "\s*=\s*", 2
if ($keyval[0] -and $keyval[1]) {
$val = $keyval[1]
# Remove extraneous quotations around a string value.
if ("'""".Contains($val.Substring(0, 1))) {
$val = $val.Substring(1, $val.Length - 2)
}
$pyvenvConfig[$keyval[0]] = $val
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
}
}
}
return $pyvenvConfig
}
<# Begin Activate script --------------------------------------------------- #>
# Determine the containing directory of this script
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$VenvExecDir = Get-Item -Path $VenvExecPath
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
# Set values required in priority: CmdLine, ConfigFile, Default
# First, get the location of the virtual environment, it might not be
# VenvExecDir if specified on the command line.
if ($VenvDir) {
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
}
else {
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
Write-Verbose "VenvDir=$VenvDir"
}
# Next, read the `pyvenv.cfg` file to determine any required value such
# as `prompt`.
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
# Next, set the prompt from the command line, or the config file, or
# just use the name of the virtual environment folder.
if ($Prompt) {
Write-Verbose "Prompt specified as argument, using '$Prompt'"
}
else {
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
$Prompt = $pyvenvCfg['prompt'];
}
else {
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
$Prompt = Split-Path -Path $venvDir -Leaf
}
}
Write-Verbose "Prompt = '$Prompt'"
Write-Verbose "VenvDir='$VenvDir'"
# Deactivate any currently active virtual environment, but leave the
# deactivate function in place.
deactivate -nondestructive
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
# that there is an activated venv.
$env:VIRTUAL_ENV = $VenvDir
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
Write-Verbose "Setting prompt to '$Prompt'"
# Set the prompt to include the env name
# Make sure _OLD_VIRTUAL_PROMPT is global
function global:_OLD_VIRTUAL_PROMPT { "" }
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
function global:prompt {
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
_OLD_VIRTUAL_PROMPT
}
$env:VIRTUAL_ENV_PROMPT = $Prompt
}
# Clear PYTHONHOME
if (Test-Path -Path Env:PYTHONHOME) {
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
Remove-Item -Path Env:PYTHONHOME
}
# Add the venv to the PATH
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"

View File

@@ -0,0 +1,63 @@
# This file must be used with "source bin/activate" *from bash*
# you cannot run it directly
deactivate () {
# reset old environment variables
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
PATH="${_OLD_VIRTUAL_PATH:-}"
export PATH
unset _OLD_VIRTUAL_PATH
fi
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
export PYTHONHOME
unset _OLD_VIRTUAL_PYTHONHOME
fi
# Call hash to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
hash -r 2> /dev/null
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
PS1="${_OLD_VIRTUAL_PS1:-}"
export PS1
unset _OLD_VIRTUAL_PS1
fi
unset VIRTUAL_ENV
unset VIRTUAL_ENV_PROMPT
if [ ! "${1:-}" = "nondestructive" ] ; then
# Self destruct!
unset -f deactivate
fi
}
# unset irrelevant variables
deactivate nondestructive
VIRTUAL_ENV='/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new'
export VIRTUAL_ENV
_OLD_VIRTUAL_PATH="$PATH"
PATH="$VIRTUAL_ENV/"bin":$PATH"
export PATH
# unset PYTHONHOME if set
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
# could use `if (set -u; : $PYTHONHOME) ;` in bash
if [ -n "${PYTHONHOME:-}" ] ; then
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
unset PYTHONHOME
fi
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
_OLD_VIRTUAL_PS1="${PS1:-}"
PS1='(venv_new) '"${PS1:-}"
export PS1
VIRTUAL_ENV_PROMPT='(venv_new) '
export VIRTUAL_ENV_PROMPT
fi
# Call hash to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
hash -r 2> /dev/null

View File

@@ -0,0 +1,26 @@
# This file must be used with "source bin/activate.csh" *from csh*.
# You cannot run it directly.
# Created by Davide Di Blasi <davidedb@gmail.com>.
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
# Unset irrelevant variables.
deactivate nondestructive
setenv VIRTUAL_ENV '/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new'
set _OLD_VIRTUAL_PATH="$PATH"
setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
set _OLD_VIRTUAL_PROMPT="$prompt"
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
set prompt = '(venv_new) '"$prompt"
setenv VIRTUAL_ENV_PROMPT '(venv_new) '
endif
alias pydoc python -m pydoc
rehash

View File

@@ -0,0 +1,69 @@
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
# (https://fishshell.com/); you cannot run it directly.
function deactivate -d "Exit virtual environment and return to normal shell environment"
# reset old environment variables
if test -n "$_OLD_VIRTUAL_PATH"
set -gx PATH $_OLD_VIRTUAL_PATH
set -e _OLD_VIRTUAL_PATH
end
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
set -e _OLD_VIRTUAL_PYTHONHOME
end
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
set -e _OLD_FISH_PROMPT_OVERRIDE
# prevents error when using nested fish instances (Issue #93858)
if functions -q _old_fish_prompt
functions -e fish_prompt
functions -c _old_fish_prompt fish_prompt
functions -e _old_fish_prompt
end
end
set -e VIRTUAL_ENV
set -e VIRTUAL_ENV_PROMPT
if test "$argv[1]" != "nondestructive"
# Self-destruct!
functions -e deactivate
end
end
# Unset irrelevant variables.
deactivate nondestructive
set -gx VIRTUAL_ENV '/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new'
set -gx _OLD_VIRTUAL_PATH $PATH
set -gx PATH "$VIRTUAL_ENV/"bin $PATH
# Unset PYTHONHOME if set.
if set -q PYTHONHOME
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
set -e PYTHONHOME
end
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
# fish uses a function instead of an env var to generate the prompt.
# Save the current fish_prompt function as the function _old_fish_prompt.
functions -c fish_prompt _old_fish_prompt
# With the original prompt function renamed, we can override with our own.
function fish_prompt
# Save the return status of the last command.
set -l old_status $status
# Output the venv prompt; color taken from the blue of the Python logo.
printf "%s%s%s" (set_color 4B8BBE) '(venv_new) ' (set_color normal)
# Restore the return status of the previous command.
echo "exit $old_status" | .
# Output the original/"old" prompt.
_old_fish_prompt
end
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
set -gx VIRTUAL_ENV_PROMPT '(venv_new) '
end

View File

@@ -0,0 +1,6 @@
#!/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new/bin/python3.11
import sys
from beanie.executors.migrate import migrations
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(migrations())

View File

@@ -0,0 +1,6 @@
#!/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new/bin/python3.11
import sys
from distro.distro import main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(main())

View File

@@ -0,0 +1,6 @@
#!/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new/bin/python3.11
import sys
from dotenv.__main__ import cli
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(cli())

View File

@@ -0,0 +1,6 @@
#!/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new/bin/python3.11
import sys
from email_validator.__main__ import main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(main())

View File

@@ -0,0 +1,6 @@
#!/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new/bin/python3.11
import sys
from fastapi.cli import main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(main())

View File

@@ -0,0 +1,6 @@
#!/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new/bin/python3.11
import sys
from httpx import main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(main())

View File

@@ -0,0 +1,6 @@
#!/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new/bin/python3.11
import sys
from openai.cli import main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(main())

View File

@@ -0,0 +1,6 @@
#!/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new/bin/python3.11
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(main())

View File

@@ -0,0 +1,6 @@
#!/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new/bin/python3.11
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(main())

View File

@@ -0,0 +1,6 @@
#!/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new/bin/python3.11
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(main())

View File

@@ -0,0 +1,6 @@
#!/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new/bin/python3.11
import sys
from rsa.cli import decrypt
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(decrypt())

View File

@@ -0,0 +1,6 @@
#!/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new/bin/python3.11
import sys
from rsa.cli import encrypt
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(encrypt())

View File

@@ -0,0 +1,6 @@
#!/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new/bin/python3.11
import sys
from rsa.cli import keygen
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(keygen())

View File

@@ -0,0 +1,6 @@
#!/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new/bin/python3.11
import sys
from rsa.util import private_to_public
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(private_to_public())

View File

@@ -0,0 +1,6 @@
#!/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new/bin/python3.11
import sys
from rsa.cli import sign
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(sign())

View File

@@ -0,0 +1,6 @@
#!/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new/bin/python3.11
import sys
from rsa.cli import verify
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(verify())

View File

@@ -0,0 +1 @@
python3.11

View File

@@ -0,0 +1 @@
python3.11

View File

@@ -0,0 +1 @@
/opt/homebrew/opt/python@3.11/bin/python3.11

View File

@@ -0,0 +1,6 @@
#!/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new/bin/python3.11
import sys
from tqdm.cli import main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(main())

View File

@@ -0,0 +1,6 @@
#!/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new/bin/python3.11
import sys
from uvicorn.main import main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(main())

View File

@@ -0,0 +1,6 @@
#!/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new/bin/python3.11
import sys
from watchfiles.cli import cli
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(cli())

View File

@@ -0,0 +1,6 @@
#!/Users/chenchen/Desktop/AI/孕期报告解读/V2/后端-孕期报告解读-api/venv_new/bin/python3.11
import sys
from websockets.cli import main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(main())

View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2022 Roman Right (Korolev)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,133 @@
#
# 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
bdf_slant = {
"R": "Roman",
"I": "Italic",
"O": "Oblique",
"RI": "Reverse Italic",
"RO": "Reverse Oblique",
"OT": "Other",
}
bdf_spacing = {"P": "Proportional", "M": "Monospaced", "C": "Cell"}
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[:9] == b"STARTCHAR":
break
id = s[9:].strip().decode("ascii")
# load symbol properties
props = {}
while True:
s = f.readline()
if not s or s[:6] == 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[:7] == 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 s[:13] != 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[:13] == 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

View File

@@ -0,0 +1,501 @@
"""
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[:4] in (b"BLP1", b"BLP2")
class BlpImageFile(ImageFile.ImageFile):
"""
Blizzard Mipmap Format
"""
format = "BLP"
format_description = "Blizzard Mipmap Format"
def _open(self) -> 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(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":
decoder_name, extents, offset, args = image.tile[0]
assert isinstance(args, tuple)
image.tile = [
ImageFile._Tile(decoder_name, extents, offset, (args[0], "CMYK"))
]
r, g, b = image.convert("RGB").split()
reversed_image = Image.merge("RGB", (b, g, r))
self.set_as_raw(reversed_image.tobytes())
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)

View File

@@ -0,0 +1,511 @@
#
# 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"),
}
def _accept(prefix: bytes) -> bool:
return prefix[:2] == 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"""
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)
file_info["colors"] = (
file_info["colors"]
if file_info.get("colors", 0)
else (1 << file_info["bits"])
)
assert isinstance(file_info["colors"], int)
if offset == 14 + file_info["header_size"] and file_info["bits"] <= 8:
offset += 4 * 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: # 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:
assert isinstance(file_info["palette_padding"], int)
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
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 = self.fd.read(2)
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) * 4 for i in (0, 255))
elif im.mode == "L":
palette = b"".join(o8(i) * 4 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")

View File

@@ -0,0 +1,76 @@
#
# 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
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[:4] == b"BUFR" or prefix[:4] == b"ZCZC"
class BufrStubImageFile(ImageFile.StubImageFile):
format = "BUFR"
format_description = "BUFR"
def _open(self) -> None:
offset = self.fp.tell()
if not _accept(self.fp.read(4)):
msg = "Not a BUFR file"
raise SyntaxError(msg)
self.fp.seek(offset)
# make something up
self._mode = "F"
self._size = 1, 1
loader = self._load()
if loader:
loader.open(self)
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")

View File

@@ -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()

View File

@@ -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, ImageFile
from ._binary import i16le as i16
from ._binary import i32le as i32
#
# --------------------------------------------------------------------
def _accept(prefix: bytes) -> bool:
return prefix[:4] == 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:
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
d, e, o, a = self.tile[0]
self.tile[0] = ImageFile._Tile(d, (0, 0) + self.size, o, a)
#
# --------------------------------------------------------------------
Image.register_open(CurImageFile.format, CurImageFile, _accept)
Image.register_extension(CurImageFile.format, ".cur")

View File

@@ -0,0 +1,80 @@
#
# 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 .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
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
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")

View File

@@ -0,0 +1,573 @@
"""
A Pillow loader 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 io
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:
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_bytes = self.fp.read(header_size - 4)
if len(header_bytes) != 120:
msg = f"Incomplete header: {len(header_bytes)} bytes"
raise OSError(msg)
header = io.BytesIO(header_bytes)
flags, height, width = struct.unpack("<3I", header.read(12))
self._size = (width, height)
extents = (0, 0) + self.size
pitch, depth, mipmaps = struct.unpack("<3I", header.read(12))
struct.unpack("<11I", header.read(44)) # reserved
# pixel format
pfsize, pfflags, fourcc, bitcount = struct.unpack("<4I", header.read(16))
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.read(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.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)
)
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)
alpha = im.mode[-1] == "A"
if im.mode[0] == "L":
pixel_flags = DDPF.LUMINANCE
rawmode = im.mode
if alpha:
rgba_mask = [0x000000FF, 0x000000FF, 0x000000FF]
else:
rgba_mask = [0xFF000000, 0xFF000000, 0xFF000000]
else:
pixel_flags = DDPF.RGB
rawmode = 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)
flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PITCH | DDSD.PIXELFORMAT
bitcount = len(im.getbands()) * 8
pitch = (im.width * bitcount + 7) // 8
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, 0, bitcount)
+ struct.pack("<4I", *rgba_mask) # dwRGBABitMask
+ struct.pack("<5I", DDSCAPS.TEXTURE, 0, 0, 0, 0)
)
ImageFile._save(im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, rawmode)])
def _accept(prefix: bytes) -> bool:
return prefix[:4] == 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")

View File

@@ -0,0 +1,474 @@
#
# 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[:4] == 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:
(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[:8] == "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
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:
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")

View File

@@ -0,0 +1,382 @@
#
# 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
"""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

View File

@@ -0,0 +1,152 @@
#
# 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[:6] == 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
value = gzip.decompress(self.fd.read())
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"])

View File

@@ -0,0 +1,175 @@
#
# 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
#
# decoder
def _accept(prefix: bytes) -> bool:
return (
len(prefix) >= 6
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
s = self.fp.read(128)
if not (_accept(s) and s[20:22] == b"\x00\x00"):
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.__offset = self.__offset + i32(s)
self.fp.seek(self.__offset)
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
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 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"])

View File

@@ -0,0 +1,134 @@
#
# 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, _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 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")
for id in range(256):
m = self.metrics[id]
if not m:
puti16(fp, (0,) * 10)
else:
puti16(fp, m[0] + m[1] + m[2])

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