diff --git a/docs/外部对接文档.md b/docs/外部对接文档.md
new file mode 100644
index 0000000..b1c9cbf
--- /dev/null
+++ b/docs/外部对接文档.md
@@ -0,0 +1,908 @@
+# HTML 转 PDF 服务 — 外部对接文档
+
+> 版本:2.0.0 | 更新日期:2026-03-16
+
+---
+
+## 1. 概述
+
+本服务提供 HTML/URL 转 PDF 和图片的能力,支持同步和异步两种调用模式:
+
+- **同步模式**:请求后直接返回文件流,适合简单、低并发场景
+- **异步模式(推荐)**:提交任务后通过轮询或回调获取结果,适合高并发、大批量场景
+
+**服务地址**:`https://pdf-service.example.com`(请替换为实际地址)
+
+---
+
+## 2. 认证
+
+服务支持 API Key 认证。如果服务端开启了认证,所有业务接口都需要携带 Token。
+
+### 2.1 获取 Token
+
+```
+POST /api/auth/token
+Content-Type: application/json
+```
+
+**请求体:**
+
+```json
+{
+ "apiKey": "your-api-key",
+ "userId": "your-user-id" // 可选
+}
+```
+
+**响应:**
+
+```json
+{
+ "accessToken": "eyJhbGciOiJIUzI1NiIs...",
+ "tokenType": "Bearer",
+ "expiresIn": 3600,
+ "expiresAt": "2026-03-16T11:00:00Z"
+}
+```
+
+### 2.2 请求认证方式
+
+服务支持两种认证方式,任选其一即可:
+
+**方式一:JWT Token(通过 API Key 换取)**
+
+先调用 `/api/auth/token` 获取 Token,然后在请求头中携带:
+
+```
+Authorization: Bearer {accessToken}
+```
+
+Token 有过期时间,过期后需要重新获取或刷新。
+
+**方式二:直接使用 API Key(无需换 Token)**
+
+每次请求直接携带 API Key,支持以下三种传递方式:
+
+```
+X-API-Key: your-api-key
+```
+
+```
+Authorization: ApiKey your-api-key
+```
+
+```
+GET /api/tasks/xxx?api_key=your-api-key
+```
+
+推荐使用 `X-API-Key` Header,Query String 方式存在 Key 泄露到日志的风险。
+
+> 以下路径无需认证:`/health`、`/swagger`、`/metrics`、`/api/auth/token`
+
+### 2.3 刷新 Token
+
+使用 JWT Token 方式时,可在 Token 过期前刷新:
+
+```
+POST /api/auth/refresh
+Authorization: Bearer {当前Token}
+```
+
+---
+
+## 3. 同步转换接口
+
+适合简单场景,请求后直接返回文件二进制流。
+
+### 3.1 HTML 转 PDF
+
+```
+POST /api/pdf/convert/html
+Content-Type: application/json
+```
+
+**请求体:**
+
+```json
+{
+ "html": "
Hello World
这是测试内容
",
+ "options": {
+ "format": "A4",
+ "landscape": false,
+ "printBackground": true,
+ "margin": {
+ "top": "10mm",
+ "right": "10mm",
+ "bottom": "10mm",
+ "left": "10mm"
+ }
+ },
+ "saveLocal": false
+}
+```
+
+**响应**:`application/pdf` 文件流
+
+### 3.2 URL 转 PDF
+
+```
+POST /api/pdf/convert/url
+Content-Type: application/json
+```
+
+**请求体:**
+
+```json
+{
+ "url": "https://example.com",
+ "waitUntil": "networkidle0",
+ "timeout": 30,
+ "options": {
+ "format": "A4",
+ "printBackground": true
+ },
+ "saveLocal": false
+}
+```
+
+### 3.3 HTML 转图片
+
+```
+POST /api/image/convert/html
+Content-Type: application/json
+```
+
+**请求体:**
+
+```json
+{
+ "html": "Hello
",
+ "options": {
+ "format": "png",
+ "quality": 90,
+ "width": 1920,
+ "height": 1080,
+ "fullPage": true,
+ "omitBackground": false
+ },
+ "saveLocal": false
+}
+```
+
+**响应**:对应格式的图片文件流(`image/png`、`image/jpeg`、`image/webp`)
+
+### 3.4 URL 转图片
+
+```
+POST /api/image/convert/url
+Content-Type: application/json
+```
+
+**请求体:**
+
+```json
+{
+ "url": "https://example.com",
+ "waitUntil": "networkidle0",
+ "timeout": 30,
+ "options": {
+ "format": "png",
+ "width": 1920,
+ "height": 1080,
+ "fullPage": true
+ },
+ "saveLocal": false
+}
+```
+
+---
+
+## 4. 异步任务接口(推荐)
+
+异步模式下,提交任务后会立即返回任务 ID,通过轮询状态或配置回调来获取结果。
+
+### 4.1 提交 PDF 任务
+
+```
+POST /api/tasks/pdf
+Content-Type: application/json
+Idempotency-Key: {可选,幂等键}
+```
+
+**请求体:**
+
+```json
+{
+ "source": {
+ "type": "html",
+ "content": "Hello World
这是测试内容
"
+ },
+ "options": {
+ "format": "A4",
+ "landscape": false,
+ "printBackground": true,
+ "margin": {
+ "top": "10mm",
+ "right": "10mm",
+ "bottom": "10mm",
+ "left": "10mm"
+ }
+ },
+ "waitUntil": "networkidle0",
+ "timeout": 30,
+ "callback": {
+ "url": "https://your-app.com/webhook/pdf-done",
+ "headers": {
+ "X-Custom-Header": "value"
+ },
+ "includeFileData": false
+ },
+ "saveLocal": true,
+ "metadata": {
+ "orderId": "12345",
+ "source": "billing-system"
+ }
+}
+```
+
+> `source.type` 为 `"html"` 时,`content` 填 HTML 字符串;为 `"url"` 时,`content` 填页面 URL。
+
+**响应(202 Accepted):**
+
+```json
+{
+ "taskId": "550e8400-e29b-41d4-a716-446655440000",
+ "status": "pending",
+ "message": "任务已创建,正在排队处理",
+ "createdAt": "2026-03-16T10:00:00Z",
+ "estimatedWaitTime": 3,
+ "queuePosition": 2,
+ "links": {
+ "self": "/api/tasks/550e8400-e29b-41d4-a716-446655440000",
+ "status": "/api/tasks/550e8400-e29b-41d4-a716-446655440000/status",
+ "download": "/api/tasks/550e8400-e29b-41d4-a716-446655440000/download",
+ "cancel": "/api/tasks/550e8400-e29b-41d4-a716-446655440000"
+ }
+}
+```
+
+### 4.2 提交图片任务
+
+```
+POST /api/tasks/image
+Content-Type: application/json
+Idempotency-Key: {可选}
+```
+
+**请求体:**
+
+```json
+{
+ "source": {
+ "type": "url",
+ "content": "https://example.com"
+ },
+ "options": {
+ "format": "png",
+ "quality": 90,
+ "width": 1920,
+ "height": 1080,
+ "fullPage": true,
+ "omitBackground": false
+ },
+ "delayAfterLoad": 1000,
+ "callback": {
+ "url": "https://your-app.com/webhook/image-done"
+ },
+ "saveLocal": true
+}
+```
+
+### 4.3 批量提交任务
+
+```
+POST /api/tasks/batch
+Content-Type: application/json
+```
+
+**请求体:**
+
+```json
+{
+ "tasks": [
+ {
+ "type": "pdf",
+ "source": { "type": "html", "content": "文档 1
" },
+ "pdfOptions": { "format": "A4", "printBackground": true }
+ },
+ {
+ "type": "pdf",
+ "source": { "type": "url", "content": "https://example.com" }
+ },
+ {
+ "type": "image",
+ "source": { "type": "html", "content": "截图
" },
+ "imageOptions": { "format": "png", "width": 1920 }
+ }
+ ],
+ "callback": {
+ "url": "https://your-app.com/webhook/batch-done"
+ },
+ "onEachComplete": false,
+ "onAllComplete": true
+}
+```
+
+**响应(202 Accepted):**
+
+```json
+{
+ "batchId": "batch-xxxx-xxxx",
+ "taskIds": ["task-1", "task-2", "task-3"],
+ "totalTasks": 3,
+ "successCount": 3,
+ "failedCount": 0,
+ "links": {
+ "status": "/api/tasks/batch/batch-xxxx-xxxx"
+ }
+}
+```
+
+### 4.4 查询任务详情
+
+```
+GET /api/tasks/{taskId}
+```
+
+**响应:**
+
+```json
+{
+ "taskId": "550e8400-...",
+ "type": "pdf",
+ "source": { "type": "html", "content": "..." },
+ "status": "completed",
+ "createdAt": "2026-03-16T10:00:00Z",
+ "startedAt": "2026-03-16T10:00:01Z",
+ "completedAt": "2026-03-16T10:00:04Z",
+ "duration": 3000,
+ "retryCount": 0,
+ "result": {
+ "fileSize": 102400,
+ "fileType": "pdf",
+ "downloadUrl": "/api/tasks/550e8400-.../download"
+ },
+ "error": null,
+ "links": {
+ "self": "/api/tasks/550e8400-...",
+ "download": "/api/tasks/550e8400-.../download"
+ }
+}
+```
+
+### 4.5 轻量级状态查询
+
+适合高频轮询场景,返回数据量小:
+
+```
+GET /api/tasks/{taskId}/status
+```
+
+**响应:**
+
+```json
+{
+ "taskId": "550e8400-...",
+ "status": "processing",
+ "createdAt": "2026-03-16T10:00:00Z",
+ "startedAt": "2026-03-16T10:00:01Z",
+ "completedAt": null
+}
+```
+
+### 4.6 下载结果文件
+
+```
+GET /api/tasks/{taskId}/download
+```
+
+**响应**:文件二进制流,Content-Type 根据任务类型自动设置。
+
+响应头包含:
+- `X-Task-Id`: 任务 ID
+- `X-Expires-At`: 文件过期时间(如有)
+
+> 任务未完成时返回 `409 Conflict`。
+
+### 4.7 取消任务
+
+```
+DELETE /api/tasks/{taskId}
+```
+
+**响应:**
+
+```json
+{
+ "taskId": "550e8400-...",
+ "status": "cancelled",
+ "message": "任务已取消"
+}
+```
+
+> 仅 `pending` 状态的任务可取消,其他状态返回 `409 Conflict`。
+
+### 4.8 重试任务
+
+```
+POST /api/tasks/{taskId}/retry
+```
+
+**响应(202 Accepted):**
+
+```json
+{
+ "taskId": "550e8400-...",
+ "status": "pending",
+ "message": "任务已重新加入队列"
+}
+```
+
+> 仅 `failed` 状态的任务可重试。
+
+### 4.9 查询批量任务状态
+
+```
+GET /api/tasks/batch/{batchId}
+```
+
+**响应:**
+
+```json
+{
+ "batchId": "batch-xxxx",
+ "status": "completed",
+ "totalTasks": 3,
+ "completedTasks": 3,
+ "failedTasks": 0,
+ "processingTasks": 0,
+ "pendingTasks": 0,
+ "createdAt": "2026-03-16T10:00:00Z",
+ "completedAt": "2026-03-16T10:00:10Z",
+ "tasks": [
+ {
+ "taskId": "task-1",
+ "status": "completed",
+ "type": "pdf",
+ "duration": 3000,
+ "downloadUrl": "/api/tasks/task-1/download",
+ "errorMessage": null
+ }
+ ]
+}
+```
+
+### 4.10 预检(Dry-run)
+
+提交前检查内容是否可渲染、是否存在 SSRF 风险:
+
+```
+POST /api/tasks/validate
+Content-Type: application/json
+```
+
+**请求体:**
+
+```json
+{
+ "source": {
+ "type": "url",
+ "content": "https://example.com"
+ }
+}
+```
+
+**响应:**
+
+```json
+{
+ "ok": true,
+ "canRender": true,
+ "suggestedQueue": "normal",
+ "estimatedRenderTimeMs": 3000,
+ "issues": [],
+ "ssrfBlocked": false
+}
+```
+
+---
+
+## 5. 配额查询
+
+### 5.1 查看当前配额
+
+```
+GET /api/quota
+```
+
+**响应:**
+
+```json
+{
+ "userId": "user-123",
+ "daily": {
+ "used": 50,
+ "limit": 1000,
+ "remaining": 950,
+ "resetAt": "2026-03-17T00:00:00Z"
+ },
+ "monthly": {
+ "used": 200,
+ "limit": 10000,
+ "remaining": 9800,
+ "resetAt": "2026-04-01T00:00:00Z"
+ },
+ "concurrent": {
+ "used": 2,
+ "limit": 10,
+ "remaining": 8
+ }
+}
+```
+
+### 5.2 检查配额是否足够
+
+提交任务前可先调用此接口:
+
+```
+GET /api/quota/check
+```
+
+**响应:**
+
+```json
+{
+ "allowed": true,
+ "denyReason": null,
+ "dailyRemaining": 950,
+ "monthlyRemaining": 9800,
+ "concurrentRemaining": 8
+}
+```
+
+---
+
+## 6. 健康检查
+
+```
+GET /health
+```
+
+可用于监控服务是否正常运行。
+
+---
+
+## 7. 回调机制
+
+异步任务支持配置回调 URL,任务完成后服务会主动 POST 通知你的系统。
+
+### 7.1 回调请求格式
+
+服务会向你配置的 `callback.url` 发送 POST 请求:
+
+```
+POST https://your-app.com/webhook/pdf-done
+Content-Type: application/json
+X-Callback-Signature: {HMAC签名}
+```
+
+回调 Body 包含任务完成信息(taskId、status、result 等)。如果 `includeFileData` 设为 `true`,回调中会包含 Base64 编码的文件数据。
+
+### 7.2 回调重试
+
+回调失败时,服务会自动进行指数退避重试。
+
+---
+
+## 8. 参数参考
+
+### 8.1 PDF 选项(options)
+
+| 字段 | 类型 | 默认值 | 说明 |
+|------|------|--------|------|
+| `format` | string | `"A4"` | 纸张格式:A4, Letter, Legal, A3 等 |
+| `landscape` | bool | `false` | 是否横向 |
+| `printBackground` | bool | `true` | 是否打印背景色/图 |
+| `width` | string | — | 自定义页面宽度(如 `"1309px"`, `"210mm"`),设置后忽略 format |
+| `height` | string | — | 自定义页面高度(如 `"926px"`, `"297mm"`),设置后忽略 format |
+| `viewportWidth` | int | — | 浏览器视口宽度(像素),控制页面渲染时的窗口宽度 |
+| `viewportHeight` | int | — | 浏览器视口高度(像素),控制页面渲染时的窗口高度 |
+| `margin.top` | string | `"10mm"` | 上边距 |
+| `margin.right` | string | `"10mm"` | 右边距 |
+| `margin.bottom` | string | `"10mm"` | 下边距 |
+| `margin.left` | string | `"10mm"` | 左边距 |
+
+> `width`/`height` 和 `format` 二选一。设置了 `width`+`height` 后 `format` 会被忽略。
+> `viewportWidth`/`viewportHeight` 用于控制浏览器渲染窗口大小,适合需要在固定分辨率下打开才能正常显示的页面。
+
+### 8.2 图片选项(options)
+
+| 字段 | 类型 | 默认值 | 说明 |
+|------|------|--------|------|
+| `format` | string | `"png"` | 图片格式:png, jpeg, webp |
+| `quality` | int | `90` | 图片质量(1-100),仅 jpeg/webp 有效 |
+| `width` | int | `1920` | 浏览器视口宽度(像素),即页面渲染分辨率的宽 |
+| `height` | int | `1080` | 浏览器视口高度(像素),即页面渲染分辨率的高 |
+| `fullPage` | bool | `true` | 是否截取整个页面(超出视口的滚动区域也会截取) |
+| `omitBackground` | bool | `false` | 是否忽略背景色(透明背景,仅 png 有效) |
+
+> `width`/`height` 直接控制浏览器窗口大小。比如传 `"width": 1309, "height": 926`,页面就会在 1309×926 的分辨率下渲染后再截图。
+
+### 8.3 waitUntil 参数
+
+控制页面加载等待策略,URL 转换时特别有用:
+
+| 值 | 说明 |
+|----|------|
+| `load` | 等待 `load` 事件触发 |
+| `domcontentloaded` | 等待 `DOMContentLoaded` 事件 |
+| `networkidle0` | 等待 500ms 内无网络请求(推荐 SPA 页面) |
+| `networkidle2` | 等待 500ms 内不超过 2 个网络请求 |
+
+### 8.4 任务状态
+
+| 状态 | 说明 |
+|------|------|
+| `pending` | 排队中 |
+| `processing` | 处理中 |
+| `completed` | 已完成 |
+| `failed` | 失败 |
+| `cancelled` | 已取消 |
+| `stalled` | 卡死(超时未完成) |
+
+---
+
+## 9. 错误处理
+
+所有错误响应遵循 RFC 7807 ProblemDetails 格式:
+
+```json
+{
+ "status": 400,
+ "title": "请求参数无效",
+ "detail": "HTML 内容不能为空"
+}
+```
+
+### 常见错误码
+
+| HTTP 状态码 | 含义 | 处理建议 |
+|-------------|------|----------|
+| 400 | 参数错误 | 检查请求体格式和必填字段 |
+| 401 | 认证失败 | 检查 Token 或 API Key |
+| 404 | 任务不存在 | 确认 taskId 是否正确 |
+| 409 | 状态冲突 | 任务状态不允许当前操作(如取消已完成的任务) |
+| 429 | 请求限流 | 降低请求频率,稍后重试 |
+| 503 | 服务繁忙 | 队列已满或服务过载,稍后重试 |
+
+---
+
+## 10. .NET SDK 接入(推荐)
+
+项目提供了 .NET 客户端 SDK,支持依赖注入,开箱即用。
+
+### 10.1 安装
+
+```bash
+dotnet add package HtmlToPdfService.Client
+```
+
+### 10.2 注册服务
+
+```csharp
+// Program.cs
+using HtmlToPdfService.Client.Extensions;
+
+builder.Services.AddHtmlToPdfClient(options =>
+{
+ options.BaseUrl = "https://pdf-service.example.com";
+ options.ApiKey = "your-api-key";
+ options.TimeoutSeconds = 120;
+ options.EnableRetry = true; // 启用指数退避重试
+ options.RetryCount = 3;
+});
+```
+
+或从配置文件读取:
+
+```json
+// appsettings.json
+{
+ "HtmlToPdfClient": {
+ "BaseUrl": "https://pdf-service.example.com",
+ "ApiKey": "your-api-key",
+ "TimeoutSeconds": 120,
+ "EnableRetry": true,
+ "RetryCount": 3
+ }
+}
+```
+
+```csharp
+builder.Services.AddHtmlToPdfClient(builder.Configuration);
+```
+
+### 10.3 使用示例
+
+```csharp
+public class ReportService
+{
+ private readonly IHtmlToPdfClient _pdfClient;
+
+ public ReportService(IHtmlToPdfClient pdfClient)
+ {
+ _pdfClient = pdfClient;
+ }
+
+ // 同步转换 — 直接拿到 PDF 字节数组
+ public async Task GenerateSimplePdfAsync(string html)
+ {
+ return await _pdfClient.ConvertHtmlToPdfAsync(html, new PdfOptions
+ {
+ Format = "A4",
+ PrintBackground = true
+ });
+ }
+
+ // 异步任务 — 提交后等待完成再下载
+ public async Task GeneratePdfAsync(string html)
+ {
+ var result = await _pdfClient.SubmitPdfTaskAsync(
+ source: new SourceInfo { Type = "html", Content = html },
+ options: new PdfOptions { Format = "A4" });
+
+ // 等待任务完成并下载(内部自动轮询)
+ return await _pdfClient.WaitAndDownloadAsync(result.TaskId);
+ }
+
+ // 异步任务 + 回调 — 提交后不等待,由回调通知
+ public async Task SubmitPdfTaskAsync(string html, string callbackUrl)
+ {
+ var result = await _pdfClient.SubmitPdfTaskAsync(
+ source: new SourceInfo { Type = "html", Content = html },
+ callback: new CallbackInfo { Url = callbackUrl });
+
+ return result.TaskId;
+ }
+}
+```
+
+### 10.4 异常处理
+
+```csharp
+using HtmlToPdfService.Client.Exceptions;
+
+try
+{
+ var pdf = await _pdfClient.ConvertHtmlToPdfAsync(html);
+}
+catch (ValidationException ex) // 400 参数错误
+{
+ logger.LogWarning("参数错误: {Message}", ex.Message);
+}
+catch (AuthenticationException ex) // 401 认证失败
+{
+ logger.LogWarning("认证失败: {Message}", ex.Message);
+}
+catch (TaskNotFoundException ex) // 404 任务不存在
+{
+ logger.LogWarning("任务不存在: {TaskId}", ex.TaskId);
+}
+catch (ServiceUnavailableException ex) // 503 服务繁忙
+{
+ logger.LogWarning("服务繁忙,稍后重试: {Message}", ex.Message);
+}
+catch (HtmlToPdfClientException ex) // 其他错误
+{
+ logger.LogError("转换失败: {Message}, 状态码: {StatusCode}", ex.Message, ex.StatusCode);
+}
+```
+
+### 10.5 SDK 配置项
+
+| 配置项 | 类型 | 默认值 | 说明 |
+|--------|------|--------|------|
+| `BaseUrl` | string | — | 服务地址(必填) |
+| `ApiKey` | string | null | API Key |
+| `TimeoutSeconds` | int | 120 | HTTP 请求超时 |
+| `EnableRetry` | bool | false | 是否启用自动重试 |
+| `RetryCount` | int | 3 | 重试次数 |
+| `RetryBaseDelayMs` | int | 500 | 重试基础延迟(指数退避) |
+| `CustomHeaders` | Dictionary | null | 自定义请求头 |
+
+---
+
+## 11. 其他语言接入
+
+非 .NET 项目直接调用 HTTP API 即可,以下是 curl 示例:
+
+### 同步 HTML 转 PDF
+
+```bash
+curl -X POST https://pdf-service.example.com/api/pdf/convert/html \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer {token}" \
+ -d '{"html": "Hello World
"}' \
+ -o document.pdf
+```
+
+### 异步提交任务
+
+```bash
+curl -X POST https://pdf-service.example.com/api/tasks/pdf \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer {token}" \
+ -d '{
+ "source": {"type": "html", "content": "Hello
"},
+ "options": {"format": "A4"}
+ }'
+```
+
+### 查询状态
+
+```bash
+curl https://pdf-service.example.com/api/tasks/{taskId}/status \
+ -H "Authorization: Bearer {token}"
+```
+
+### 下载文件
+
+```bash
+curl https://pdf-service.example.com/api/tasks/{taskId}/download \
+ -H "Authorization: Bearer {token}" \
+ -o result.pdf
+```
+
+---
+
+## 12. 典型接入流程
+
+### 方式一:同步调用(简单直接)
+
+```
+客户端 → POST /api/pdf/convert/html → 等待 → 返回 PDF 文件流
+```
+
+适合:单次转换、低并发、对延迟不敏感。
+
+### 方式二:异步轮询
+
+```
+客户端 → POST /api/tasks/pdf → 返回 taskId
+客户端 → GET /api/tasks/{taskId}/status → 轮询直到 completed
+客户端 → GET /api/tasks/{taskId}/download → 下载文件
+```
+
+适合:需要异步处理但不方便接收回调。
+
+### 方式三:异步回调(推荐)
+
+```
+客户端 → POST /api/tasks/pdf(带 callback.url)→ 返回 taskId
+服务端 → 处理完成后 POST 回调到 callback.url
+客户端 → 收到回调后 GET /api/tasks/{taskId}/download → 下载文件
+```
+
+适合:高并发、批量处理、生产环境。
+
+---
+
+## 13. 注意事项
+
+1. **幂等性**:异步接口支持 `Idempotency-Key` 请求头,相同 Key 不会重复创建任务
+2. **文件有效期**:生成的文件有保留时间限制(默认 7 天),请及时下载
+3. **SSRF 防护**:URL 模式下,服务会阻止访问内网地址(10.x、192.168.x、127.x 等)
+4. **请求限流**:服务有 IP/用户维度的速率限制,超限返回 429
+5. **超时设置**:复杂页面建议适当增大 `timeout` 参数
+6. **SPA 页面**:转换 React/Vue/Angular 等 SPA 页面时,建议 `waitUntil` 设为 `networkidle0`
diff --git a/src/HtmlToPdfService.Api/Controllers/PdfController.cs b/src/HtmlToPdfService.Api/Controllers/PdfController.cs
index 69219de..b336c58 100644
--- a/src/HtmlToPdfService.Api/Controllers/PdfController.cs
+++ b/src/HtmlToPdfService.Api/Controllers/PdfController.cs
@@ -59,7 +59,11 @@ public class PdfController : ControllerBase
MarginTop = request.Options.Margin?.Top ?? "10mm",
MarginRight = request.Options.Margin?.Right ?? "10mm",
MarginBottom = request.Options.Margin?.Bottom ?? "10mm",
- MarginLeft = request.Options.Margin?.Left ?? "10mm"
+ MarginLeft = request.Options.Margin?.Left ?? "10mm",
+ Width = request.Options.Width,
+ Height = request.Options.Height,
+ ViewportWidth = request.Options.ViewportWidth,
+ ViewportHeight = request.Options.ViewportHeight
} : null;
var result = await _pdfService.ConvertHtmlToPdfAsync(request.Html, options, cancellationToken);
@@ -145,7 +149,11 @@ public class PdfController : ControllerBase
MarginTop = request.Options.Margin?.Top ?? "10mm",
MarginRight = request.Options.Margin?.Right ?? "10mm",
MarginBottom = request.Options.Margin?.Bottom ?? "10mm",
- MarginLeft = request.Options.Margin?.Left ?? "10mm"
+ MarginLeft = request.Options.Margin?.Left ?? "10mm",
+ Width = request.Options.Width,
+ Height = request.Options.Height,
+ ViewportWidth = request.Options.ViewportWidth,
+ ViewportHeight = request.Options.ViewportHeight
} : null;
var result = await _pdfService.ConvertUrlToPdfAsync(
diff --git a/src/HtmlToPdfService.Api/Controllers/TasksController.cs b/src/HtmlToPdfService.Api/Controllers/TasksController.cs
index 8198e1c..559aa7a 100644
--- a/src/HtmlToPdfService.Api/Controllers/TasksController.cs
+++ b/src/HtmlToPdfService.Api/Controllers/TasksController.cs
@@ -52,6 +52,10 @@ public class TasksController : ControllerBase
Format = request.Options.Format,
Landscape = request.Options.Landscape,
PrintBackground = request.Options.PrintBackground,
+ Width = request.Options.Width,
+ Height = request.Options.Height,
+ ViewportWidth = request.Options.ViewportWidth,
+ ViewportHeight = request.Options.ViewportHeight,
Margin = request.Options.Margin != null ? new MarginRequest
{
Top = request.Options.Margin.Top,
@@ -726,6 +730,10 @@ public class PdfOptionsDto
public bool? Landscape { get; set; }
public bool? PrintBackground { get; set; }
public MarginDto? Margin { get; set; }
+ public string? Width { get; set; }
+ public string? Height { get; set; }
+ public int? ViewportWidth { get; set; }
+ public int? ViewportHeight { get; set; }
}
public class MarginDto
diff --git a/src/HtmlToPdfService.Client/Models/PdfOptions.cs b/src/HtmlToPdfService.Client/Models/PdfOptions.cs
index c8c6846..34f21dd 100644
--- a/src/HtmlToPdfService.Client/Models/PdfOptions.cs
+++ b/src/HtmlToPdfService.Client/Models/PdfOptions.cs
@@ -30,6 +30,32 @@ public class PdfOptions
///
[JsonPropertyName("margin")]
public MarginOptions? Margin { get; set; }
+
+ ///
+ /// 自定义页面宽度,支持单位:px, in, cm, mm(如 "1309px", "210mm")
+ /// 设置后将忽略 Format 参数
+ ///
+ [JsonPropertyName("width")]
+ public string? Width { get; set; }
+
+ ///
+ /// 自定义页面高度,支持单位:px, in, cm, mm(如 "926px", "297mm")
+ /// 设置后将忽略 Format 参数
+ ///
+ [JsonPropertyName("height")]
+ public string? Height { get; set; }
+
+ ///
+ /// 浏览器视口宽度(像素),控制页面渲染时的窗口宽度
+ ///
+ [JsonPropertyName("viewportWidth")]
+ public int? ViewportWidth { get; set; }
+
+ ///
+ /// 浏览器视口高度(像素),控制页面渲染时的窗口高度
+ ///
+ [JsonPropertyName("viewportHeight")]
+ public int? ViewportHeight { get; set; }
}
///
diff --git a/src/HtmlToPdfService.Core/Models/ConversionOptions.cs b/src/HtmlToPdfService.Core/Models/ConversionOptions.cs
index 0b0fb21..8ca32c9 100644
--- a/src/HtmlToPdfService.Core/Models/ConversionOptions.cs
+++ b/src/HtmlToPdfService.Core/Models/ConversionOptions.cs
@@ -13,6 +13,28 @@ public class PdfConversionOptions
public string MarginBottom { get; set; } = "10mm";
public string MarginLeft { get; set; } = "10mm";
+ ///
+ /// 自定义页面宽度,支持单位:px, in, cm, mm(如 "1309px", "210mm")
+ /// 设置后将忽略 Format 参数
+ ///
+ public string? Width { get; set; }
+
+ ///
+ /// 自定义页面高度,支持单位:px, in, cm, mm(如 "926px", "297mm")
+ /// 设置后将忽略 Format 参数
+ ///
+ public string? Height { get; set; }
+
+ ///
+ /// 浏览器视口宽度(像素),控制页面渲染时的窗口宽度
+ ///
+ public int? ViewportWidth { get; set; }
+
+ ///
+ /// 浏览器视口高度(像素),控制页面渲染时的窗口高度
+ ///
+ public int? ViewportHeight { get; set; }
+
/// 是否显示页眉页脚
public bool DisplayHeaderFooter { get; set; }
diff --git a/src/HtmlToPdfService.Core/Services/PuppeteerPdfService.cs b/src/HtmlToPdfService.Core/Services/PuppeteerPdfService.cs
index 7f80f4e..65e7461 100644
--- a/src/HtmlToPdfService.Core/Services/PuppeteerPdfService.cs
+++ b/src/HtmlToPdfService.Core/Services/PuppeteerPdfService.cs
@@ -60,6 +60,16 @@ public class PuppeteerPdfService : IPdfService
// 创建新页面
page = await browser.NewPageAsync();
+ // 设置浏览器视口尺寸
+ if (options?.ViewportWidth > 0 || options?.ViewportHeight > 0)
+ {
+ await page.SetViewportAsync(new ViewPortOptions
+ {
+ Width = options?.ViewportWidth ?? 1920,
+ Height = options?.ViewportHeight ?? 1080
+ });
+ }
+
// 设置 HTML 内容
await page.SetContentAsync(html, new NavigationOptions
{
@@ -168,6 +178,16 @@ public class PuppeteerPdfService : IPdfService
// 创建新页面
page = await browser.NewPageAsync();
+ // 设置浏览器视口尺寸
+ if (options?.ViewportWidth > 0 || options?.ViewportHeight > 0)
+ {
+ await page.SetViewportAsync(new ViewPortOptions
+ {
+ Width = options?.ViewportWidth ?? 1920,
+ Height = options?.ViewportHeight ?? 1080
+ });
+ }
+
// 导航到 URL
var navigationOptions = new NavigationOptions
{
@@ -249,7 +269,6 @@ public class PuppeteerPdfService : IPdfService
var pdfOptions = new PdfOptions
{
- Format = ParsePaperFormat(customOptions?.Format ?? defaultOptions.Format),
Landscape = customOptions?.Landscape ?? defaultOptions.Landscape,
PrintBackground = customOptions?.PrintBackground ?? defaultOptions.PrintBackground,
PreferCSSPageSize = defaultOptions.PreferCSSPageSize,
@@ -262,6 +281,17 @@ public class PuppeteerPdfService : IPdfService
}
};
+ // 自定义宽高优先于预设纸张格式
+ if (!string.IsNullOrEmpty(customOptions?.Width) && !string.IsNullOrEmpty(customOptions?.Height))
+ {
+ pdfOptions.Width = customOptions.Width;
+ pdfOptions.Height = customOptions.Height;
+ }
+ else
+ {
+ pdfOptions.Format = ParsePaperFormat(customOptions?.Format ?? defaultOptions.Format);
+ }
+
// 页眉页脚设置
if (customOptions?.DisplayHeaderFooter == true)
{
diff --git a/src/HtmlToPdfService.Queue/Models/ConversionTask.cs b/src/HtmlToPdfService.Queue/Models/ConversionTask.cs
index 97f945e..24ec0b9 100644
--- a/src/HtmlToPdfService.Queue/Models/ConversionTask.cs
+++ b/src/HtmlToPdfService.Queue/Models/ConversionTask.cs
@@ -74,6 +74,12 @@ public class ConversionTask
/// 页边距(JSON)
public string? MarginJson { get; set; }
+ /// PDF 自定义宽度(如 "1309px", "210mm"),设置后忽略 PdfFormat
+ public string? PdfWidth { get; set; }
+
+ /// PDF 自定义高度(如 "926px", "297mm"),设置后忽略 PdfFormat
+ public string? PdfHeight { get; set; }
+
// 图片选项
/// 图片格式:png / jpeg / webp
public string? ImageFormat { get; set; }
diff --git a/src/HtmlToPdfService.Queue/TaskOrchestrator.cs b/src/HtmlToPdfService.Queue/TaskOrchestrator.cs
index f842412..a1a0dd0 100644
--- a/src/HtmlToPdfService.Queue/TaskOrchestrator.cs
+++ b/src/HtmlToPdfService.Queue/TaskOrchestrator.cs
@@ -131,6 +131,10 @@ public class TaskOrchestrator : ITaskOrchestrator
PdfFormat = request.Options?.Format,
Landscape = request.Options?.Landscape,
PrintBackground = request.Options?.PrintBackground,
+ PdfWidth = request.Options?.Width,
+ PdfHeight = request.Options?.Height,
+ ViewportWidth = request.Options?.ViewportWidth,
+ ViewportHeight = request.Options?.ViewportHeight,
SaveLocal = request.SaveLocal ?? _options.Storage.SaveLocalCopy,
CallbackUrl = request.Callback?.Url,
CallbackHeaders = request.Callback?.Headers,
@@ -630,6 +634,10 @@ public class PdfOptionsRequest
public bool? Landscape { get; set; }
public bool? PrintBackground { get; set; }
public MarginRequest? Margin { get; set; }
+ public string? Width { get; set; }
+ public string? Height { get; set; }
+ public int? ViewportWidth { get; set; }
+ public int? ViewportHeight { get; set; }
}
///
diff --git a/src/HtmlToPdfService.Queue/Workers/TaskProcessor.cs b/src/HtmlToPdfService.Queue/Workers/TaskProcessor.cs
index bfc91f6..84ef6e8 100644
--- a/src/HtmlToPdfService.Queue/Workers/TaskProcessor.cs
+++ b/src/HtmlToPdfService.Queue/Workers/TaskProcessor.cs
@@ -188,7 +188,11 @@ public class TaskProcessor : ITaskProcessor
MarginTop = _options.DefaultPdfOptions.Margin.Top,
MarginRight = _options.DefaultPdfOptions.Margin.Right,
MarginBottom = _options.DefaultPdfOptions.Margin.Bottom,
- MarginLeft = _options.DefaultPdfOptions.Margin.Left
+ MarginLeft = _options.DefaultPdfOptions.Margin.Left,
+ Width = task.PdfWidth,
+ Height = task.PdfHeight,
+ ViewportWidth = task.ViewportWidth,
+ ViewportHeight = task.ViewportHeight
};
}