From f7495e5fd485e9a5e332b893c52e6a743e7aa16d Mon Sep 17 00:00:00 2001 From: zpc Date: Mon, 16 Mar 2026 18:33:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81PDF=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E9=A1=B5=E9=9D=A2=E5=B0=BA=E5=AF=B8=E5=92=8C=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=99=A8=E8=A7=86=E5=8F=A3=E8=AE=BE=E7=BD=AE=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=A4=96=E9=83=A8=E5=AF=B9=E6=8E=A5=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PdfConversionOptions 新增 Width/Height(自定义纸张尺寸)和 ViewportWidth/ViewportHeight(浏览器视口) - PuppeteerPdfService 渲染前设置视口,BuildPdfOptions 支持自定义宽高优先于预设Format - ConversionTask 新增 PdfWidth/PdfHeight 字段 - 全链路透传:API DTO → TaskOrchestrator → TaskProcessor → PuppeteerPdfService - Client SDK PdfOptions 同步更新 - 新增 docs/外部对接文档.md --- docs/外部对接文档.md | 908 ++++++++++++++++++ .../Controllers/PdfController.cs | 12 +- .../Controllers/TasksController.cs | 8 + .../Models/PdfOptions.cs | 26 + .../Models/ConversionOptions.cs | 22 + .../Services/PuppeteerPdfService.cs | 32 +- .../Models/ConversionTask.cs | 6 + .../TaskOrchestrator.cs | 8 + .../Workers/TaskProcessor.cs | 6 +- 9 files changed, 1024 insertions(+), 4 deletions(-) create mode 100644 docs/外部对接文档.md 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 }; }