init
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
models/
|
||||||
|
openocr_output/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.git/
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
OPENOCR_OUTPUT_DIR=data/output
|
||||||
|
OPENOCR_UPLOAD_DIR=data/uploads
|
||||||
|
OPENOCR_USE_GPU=false
|
||||||
|
OPENOCR_AUTO_DOWNLOAD=true
|
||||||
|
OPENOCR_LAYOUT_THRESHOLD=0.4
|
||||||
|
OPENOCR_MAX_LENGTH=2048
|
||||||
|
OPENOCR_CORS_ORIGINS=*
|
||||||
|
OPENOCR_LOG_LEVEL=info
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
*.egg
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Test / lint / type check
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
|
||||||
|
# Environment and secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Runtime data and model cache
|
||||||
|
data/
|
||||||
|
models/
|
||||||
|
openocr_output/
|
||||||
|
.cache/
|
||||||
|
.huggingface/
|
||||||
|
.modelscope/
|
||||||
|
|
||||||
|
# Large model weights (downloaded at runtime)
|
||||||
|
*.onnx
|
||||||
|
*.pth
|
||||||
|
*.pt
|
||||||
|
*.bin
|
||||||
|
*.safetensors
|
||||||
|
*.whl
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# IDE / OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
FROM python:3.10-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY pyproject.toml README.md ./
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
RUN pip install --upgrade pip \
|
||||||
|
&& pip install .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "openocr_markdown.api:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# OpenOCR Markdown Service
|
||||||
|
|
||||||
|
一个精简版图片/PDF 转 Markdown 服务,核心依赖 `openocr-python==0.1.5`,保留文档解析能力,去掉训练、评测和实验脚本。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- `POST /api/recognize`:上传图片或 PDF,生成 Markdown 和 JSON。
|
||||||
|
- `GET /api/history`:查看已解析文件列表。
|
||||||
|
- `GET /output/...`:访问生成的 Markdown、JSON 和原始文件。
|
||||||
|
- `openocr-md`:命令行直接解析单个图片/PDF。
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -e .
|
||||||
|
cp .env.example .env
|
||||||
|
uvicorn openocr_markdown.api:app --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m venv .venv
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
pip install -e .
|
||||||
|
Copy-Item .env.example .env
|
||||||
|
uvicorn openocr_markdown.api:app --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
首次运行会自动下载 OpenOCR 所需模型,耗时取决于网络和机器性能。
|
||||||
|
|
||||||
|
## API 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -F "file=@examples/page.png" http://127.0.0.1:8000/api/recognize
|
||||||
|
curl http://127.0.0.1:8000/api/history
|
||||||
|
```
|
||||||
|
|
||||||
|
重复上传同名文件默认复用已有结果;需要重新识别时增加 `force=true`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -F "file=@examples/page.png" -F "force=true" http://127.0.0.1:8000/api/recognize
|
||||||
|
```
|
||||||
|
|
||||||
|
## 命令行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openocr-md path/to/document.pdf --output-dir data/output
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker 部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
服务启动后访问:
|
||||||
|
|
||||||
|
- API 文档:`http://服务器IP:8000/docs`
|
||||||
|
- 健康检查:`http://服务器IP:8000/health`
|
||||||
|
- 输出文件:`http://服务器IP:8000/output/...`
|
||||||
|
|
||||||
|
## 关键配置
|
||||||
|
|
||||||
|
通过 `.env` 或服务器环境变量配置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
OPENOCR_OUTPUT_DIR=data/output
|
||||||
|
OPENOCR_UPLOAD_DIR=data/uploads
|
||||||
|
OPENOCR_USE_GPU=false
|
||||||
|
OPENOCR_AUTO_DOWNLOAD=true
|
||||||
|
OPENOCR_LAYOUT_THRESHOLD=0.4
|
||||||
|
OPENOCR_MAX_LENGTH=2048
|
||||||
|
OPENOCR_CORS_ORIGINS=*
|
||||||
|
```
|
||||||
|
|
||||||
|
如果远程服务器有 GPU,可以先安装带 GPU 的运行环境,再把 `OPENOCR_USE_GPU=true`。当前 Dockerfile 默认是 CPU 版,适合先验证完整链路。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├── src/openocr_markdown/
|
||||||
|
│ ├── api.py # FastAPI 服务
|
||||||
|
│ ├── cli.py # 命令行入口
|
||||||
|
│ ├── config.py # 环境变量配置
|
||||||
|
│ ├── engine.py # OpenOCR 模型加载和解析封装
|
||||||
|
│ └── schemas.py # API 响应模型
|
||||||
|
├── tests/
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── pyproject.toml
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
openocr-markdown:
|
||||||
|
build: .
|
||||||
|
container_name: openocr-markdown
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "openocr-markdown-service"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "FastAPI service that converts images or PDFs to Markdown with OpenOCR."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115",
|
||||||
|
"uvicorn[standard]>=0.30",
|
||||||
|
"python-multipart>=0.0.9",
|
||||||
|
"pydantic-settings>=2.4",
|
||||||
|
"openocr-python==0.1.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
server = [
|
||||||
|
"torch",
|
||||||
|
"torchvision",
|
||||||
|
]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"ruff>=0.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
openocr-md-api = "openocr_markdown.api:main"
|
||||||
|
openocr-md = "openocr_markdown.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py310"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""OpenOCR Markdown service package."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .engine import get_doc_parser, parse_to_outputs
|
||||||
|
from .schemas import HistoryItem, HistoryResponse, RecognizeResponse
|
||||||
|
|
||||||
|
|
||||||
|
SUPPORTED_SUFFIXES = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".pdf"}
|
||||||
|
|
||||||
|
|
||||||
|
def safe_stem(filename: str) -> str:
|
||||||
|
stem = Path(filename).stem.strip()
|
||||||
|
stem = re.sub(r"[^a-zA-Z0-9._-]+", "_", stem)
|
||||||
|
return stem.strip("._-") or f"document_{uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
def find_first(path: Path, patterns: tuple[str, ...]) -> Path | None:
|
||||||
|
for pattern in patterns:
|
||||||
|
match = next(path.glob(pattern), None)
|
||||||
|
if match:
|
||||||
|
return match
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
settings.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
app = FastAPI(title=settings.app_name)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origin_list,
|
||||||
|
allow_credentials=settings.cors_origin_list != ["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def warm_up_model() -> None:
|
||||||
|
get_doc_parser()
|
||||||
|
|
||||||
|
@app.get("/", include_in_schema=False)
|
||||||
|
async def root() -> RedirectResponse:
|
||||||
|
return RedirectResponse(url="/docs")
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@app.get("/api/history", response_model=HistoryResponse)
|
||||||
|
async def history() -> HistoryResponse:
|
||||||
|
items: list[HistoryItem] = []
|
||||||
|
for item_dir in settings.output_dir.iterdir():
|
||||||
|
if not item_dir.is_dir():
|
||||||
|
continue
|
||||||
|
markdown_file = find_first(item_dir, ("*.md",))
|
||||||
|
if not markdown_file:
|
||||||
|
continue
|
||||||
|
json_file = find_first(item_dir, ("*.json",))
|
||||||
|
source_file = find_first(item_dir, ("origin_*",))
|
||||||
|
items.append(
|
||||||
|
HistoryItem(
|
||||||
|
id=item_dir.name,
|
||||||
|
markdown_url=f"/output/{item_dir.name}/{markdown_file.name}",
|
||||||
|
json_url=f"/output/{item_dir.name}/{json_file.name}" if json_file else None,
|
||||||
|
source_url=f"/output/{item_dir.name}/{source_file.name}" if source_file else None,
|
||||||
|
updated_at=markdown_file.stat().st_mtime,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
items.sort(key=lambda item: item.updated_at, reverse=True)
|
||||||
|
return HistoryResponse(data=items)
|
||||||
|
|
||||||
|
@app.post("/api/recognize", response_model=RecognizeResponse)
|
||||||
|
async def recognize(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
force: bool = Form(default=False),
|
||||||
|
) -> RecognizeResponse:
|
||||||
|
if not file.filename:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing filename.")
|
||||||
|
|
||||||
|
suffix = Path(file.filename).suffix.lower()
|
||||||
|
if suffix not in SUPPORTED_SUFFIXES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unsupported file type: {suffix}")
|
||||||
|
|
||||||
|
doc_id = safe_stem(file.filename)
|
||||||
|
output_dir = settings.output_dir / doc_id
|
||||||
|
markdown_file = output_dir / f"{doc_id}.md"
|
||||||
|
|
||||||
|
if output_dir.exists():
|
||||||
|
if force:
|
||||||
|
shutil.rmtree(output_dir)
|
||||||
|
elif markdown_file.exists():
|
||||||
|
return RecognizeResponse(
|
||||||
|
id=doc_id,
|
||||||
|
status="success",
|
||||||
|
message="Existing result reused. Set force=true to re-run.",
|
||||||
|
markdown_url=f"/output/{doc_id}/{markdown_file.name}",
|
||||||
|
json_url=f"/output/{doc_id}/{doc_id}.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
upload_path = settings.upload_dir / f"{doc_id}{suffix}"
|
||||||
|
source_path = output_dir / f"origin_{Path(file.filename).name}"
|
||||||
|
|
||||||
|
with upload_path.open("wb") as buffer:
|
||||||
|
shutil.copyfileobj(file.file, buffer)
|
||||||
|
|
||||||
|
started_at = time.time()
|
||||||
|
try:
|
||||||
|
parse_to_outputs(upload_path, settings.output_dir)
|
||||||
|
if upload_path.exists():
|
||||||
|
shutil.move(str(upload_path), source_path)
|
||||||
|
except Exception as exc:
|
||||||
|
if upload_path.exists():
|
||||||
|
upload_path.unlink()
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
elapsed = round(time.time() - started_at, 2)
|
||||||
|
return RecognizeResponse(
|
||||||
|
id=doc_id,
|
||||||
|
status="success",
|
||||||
|
message=f"Parsed successfully in {elapsed}s.",
|
||||||
|
markdown_url=f"/output/{doc_id}/{doc_id}.md",
|
||||||
|
json_url=f"/output/{doc_id}/{doc_id}.json",
|
||||||
|
source_url=f"/output/{doc_id}/{source_path.name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.mount("/output", StaticFiles(directory=settings.output_dir), name="output")
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
uvicorn.run(
|
||||||
|
"openocr_markdown.api:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=8000,
|
||||||
|
reload=False,
|
||||||
|
log_level=settings.log_level,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .engine import parse_to_outputs
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Convert an image or PDF to Markdown with OpenOCR.")
|
||||||
|
parser.add_argument("input_path", type=Path, help="Image or PDF path.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--output-dir",
|
||||||
|
type=Path,
|
||||||
|
default=settings.output_dir,
|
||||||
|
help="Directory where OpenOCR outputs are written.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.input_path.exists():
|
||||||
|
raise SystemExit(f"Input file does not exist: {args.input_path}")
|
||||||
|
|
||||||
|
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
parse_to_outputs(args.input_path, args.output_dir)
|
||||||
|
print(f"Done. Results written under: {args.output_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Runtime settings loaded from environment variables."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
|
|
||||||
|
app_name: str = "OpenOCR Markdown Service"
|
||||||
|
output_dir: Path = Field(default=Path("data/output"), alias="OPENOCR_OUTPUT_DIR")
|
||||||
|
upload_dir: Path = Field(default=Path("data/uploads"), alias="OPENOCR_UPLOAD_DIR")
|
||||||
|
use_gpu: bool = Field(default=False, alias="OPENOCR_USE_GPU")
|
||||||
|
auto_download: bool = Field(default=True, alias="OPENOCR_AUTO_DOWNLOAD")
|
||||||
|
layout_threshold: float = Field(default=0.4, alias="OPENOCR_LAYOUT_THRESHOLD")
|
||||||
|
max_length: int = Field(default=2048, alias="OPENOCR_MAX_LENGTH")
|
||||||
|
cors_origins: str = Field(default="*", alias="OPENOCR_CORS_ORIGINS")
|
||||||
|
log_level: Literal["debug", "info", "warning", "error"] = Field(
|
||||||
|
default="info",
|
||||||
|
alias="OPENOCR_LOG_LEVEL",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origin_list(self) -> list[str]:
|
||||||
|
if self.cors_origins.strip() == "*":
|
||||||
|
return ["*"]
|
||||||
|
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from openocr import OpenOCR
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_doc_parser() -> OpenOCR:
|
||||||
|
"""Load the document parser once per worker process."""
|
||||||
|
|
||||||
|
return OpenOCR(
|
||||||
|
task="doc",
|
||||||
|
use_gpu=settings.use_gpu,
|
||||||
|
auto_download=settings.auto_download,
|
||||||
|
use_layout_detection=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_to_outputs(input_path: Path, output_dir: Path) -> dict[str, Any]:
|
||||||
|
parser = get_doc_parser()
|
||||||
|
result = parser(
|
||||||
|
image_path=str(input_path),
|
||||||
|
layout_threshold=settings.layout_threshold,
|
||||||
|
max_length=settings.max_length,
|
||||||
|
)
|
||||||
|
parser.save_to_markdown(result, str(output_dir))
|
||||||
|
parser.save_to_json(result, str(output_dir))
|
||||||
|
return result
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class RecognizeResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
status: str
|
||||||
|
message: str
|
||||||
|
markdown_url: str | None = None
|
||||||
|
json_url: str | None = None
|
||||||
|
source_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
markdown_url: str
|
||||||
|
json_url: str | None = None
|
||||||
|
source_url: str | None = None
|
||||||
|
updated_at: float
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryResponse(BaseModel):
|
||||||
|
data: list[HistoryItem]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from openocr_markdown.api import safe_stem
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_stem_keeps_simple_names() -> None:
|
||||||
|
assert safe_stem("invoice-01.png") == "invoice-01"
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_stem_replaces_unsafe_names() -> None:
|
||||||
|
assert safe_stem("../report 01!.pdf") == "report_01"
|
||||||
Reference in New Issue
Block a user