diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0de5840 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +deploy/entrypoint.sh text eol=lf diff --git a/.gitignore b/.gitignore index 3884f09..389dfd9 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ Thumbs.db tmp/ playwright-report/ test-results/ +output/ *.tsbuildinfo diff --git a/README.md b/README.md index 8697a4e..5b1dd11 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,12 @@ - Markdown、Front Matter、安全过滤和扩展语法渲染核心; - 表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid; - iframe 隔离的真实分页预览、双向滚动同步与主题切换; +- Chromium PDF 下载、精确 PDF.js 预览与导出性能观测; - 五种纸张、页边距、页眉、页码和浏览器配置缓存; - 内置 Typora 风格主题,以及仅供本机使用的三套白色 Typora 默认主题导入工具; -- Docker 与 PDF 渲染目录预留。 +- 前端、Fastify、Nginx 和 Playwright Chromium 的 AIO 容器部署。 -Chromium PDF 下载和本地资源文件处理将在后续阶段实现。 +本地相对资源文件处理将在后续阶段实现。 ## 本地开发 @@ -96,6 +97,16 @@ npm run theme:import-typora -- --replace 自定义主题目录、`theme.json`、CSS 组合顺序、资源引用和安全限制详见 [主题开发指南](docs/THEMES.md)。 +## Docker Desktop 部署 + +```powershell +docker compose -f deploy\compose.yaml up --build -d +``` + +默认访问地址为 http://localhost:8080。Compose 会将 `.local/themes` +作为只读主题目录挂载;端口、主题目录和 PDF 并发参数的配置方式详见 +[AIO 容器部署说明](deploy/README.md)。 + ## 测试与构建 ```powershell @@ -107,4 +118,5 @@ git diff --check ## 隐私原则 -文档和资源文件仅用于当前转换请求。PDF 阶段将为每次请求创建隔离的临时目录,并在成功或失败后统一清理,不接入数据库或文档历史存储。 +文档内容仅用于当前渲染和转换请求,不接入数据库或文档历史存储。 +相对资源文件支持将在后续阶段通过隔离临时目录和请求结束清理实现。 diff --git a/apps/server/src/pdf-engine.ts b/apps/server/src/pdf-engine.ts index b95578a..35871c8 100644 --- a/apps/server/src/pdf-engine.ts +++ b/apps/server/src/pdf-engine.ts @@ -56,6 +56,12 @@ export interface PdfEngineOptions { launchBrowser?: () => Promise; } +export interface PdfEngineRuntimeLimits { + concurrency: number; + maxQueue: number; + timeoutMs: number; +} + interface PagedRuntimeResult { pageCount: number; contentHeight: number; @@ -168,6 +174,53 @@ function normalizeRenderOrigin(value: string) { return url.origin; } +function readIntegerEnvironment( + environment: NodeJS.ProcessEnv, + name: string, + fallback: number, + minimum: number +) { + const rawValue = environment[name]; + if (rawValue === undefined || rawValue.trim() === "") { + return fallback; + } + + if (!/^\d+$/.test(rawValue)) { + throw new Error(`${name} 必须是整数`); + } + + const value = Number.parseInt(rawValue, 10); + if (!Number.isSafeInteger(value) || value < minimum) { + throw new Error(`${name} 不能小于 ${minimum}`); + } + return value; +} + +export function readPdfEngineRuntimeLimits( + environment: NodeJS.ProcessEnv = process.env +): PdfEngineRuntimeLimits { + return { + concurrency: readIntegerEnvironment( + environment, + "PDF_CONCURRENCY", + 2, + 1 + ), + maxQueue: readIntegerEnvironment( + environment, + "PDF_MAX_QUEUE", + 8, + 0 + ), + timeoutMs: readIntegerEnvironment( + environment, + "PDF_TIMEOUT_MS", + 60_000, + 1_000 + ) + }; +} + export function isAllowedPdfRequestUrl( requestUrl: string, renderOrigin: string @@ -189,6 +242,7 @@ export class PlaywrightPdfGenerator implements PdfGenerator { private closed = false; constructor(options: PdfEngineOptions = {}) { + const runtimeLimits = readPdfEngineRuntimeLimits(); this.renderOrigin = normalizeRenderOrigin( options.renderOrigin ?? process.env.PDF_RENDER_ORIGIN ?? @@ -198,10 +252,10 @@ export class PlaywrightPdfGenerator implements PdfGenerator { "/preview-frame.html?target=pdf", this.renderOrigin ).href; - this.timeoutMs = options.timeoutMs ?? 60_000; + this.timeoutMs = options.timeoutMs ?? runtimeLimits.timeoutMs; this.gate = new ConcurrencyGate( - options.concurrency ?? 2, - options.maxQueue ?? 8 + options.concurrency ?? runtimeLimits.concurrency, + options.maxQueue ?? runtimeLimits.maxQueue ); this.launchBrowser = options.launchBrowser ?? diff --git a/apps/server/tests/pdf-engine.test.ts b/apps/server/tests/pdf-engine.test.ts index e48bd47..c80235b 100644 --- a/apps/server/tests/pdf-engine.test.ts +++ b/apps/server/tests/pdf-engine.test.ts @@ -8,6 +8,7 @@ import { PdfRenderTimeoutError, PlaywrightPdfGenerator, isAllowedPdfRequestUrl, + readPdfEngineRuntimeLimits, type PdfRenderPayload } from "../src/pdf-engine.js"; @@ -102,6 +103,41 @@ describe("PDF 并发控制", () => { }); }); +describe("PDF 运行参数", () => { + it("读取容器环境变量", () => { + expect( + readPdfEngineRuntimeLimits({ + PDF_CONCURRENCY: "1", + PDF_MAX_QUEUE: "4", + PDF_TIMEOUT_MS: "120000" + }) + ).toEqual({ + concurrency: 1, + maxQueue: 4, + timeoutMs: 120_000 + }); + }); + + it("缺少环境变量时使用默认值", () => { + expect(readPdfEngineRuntimeLimits({})).toEqual({ + concurrency: 2, + maxQueue: 8, + timeoutMs: 60_000 + }); + }); + + it.each([ + ["PDF_CONCURRENCY", "0"], + ["PDF_MAX_QUEUE", "-1"], + ["PDF_TIMEOUT_MS", "999"], + ["PDF_CONCURRENCY", "1.5"] + ])("拒绝无效的 %s", (name, value) => { + expect(() => + readPdfEngineRuntimeLimits({ [name]: value }) + ).toThrow(name); + }); +}); + describe("PDF 网络策略", () => { it("只允许渲染同源和内嵌资源", () => { const origin = "http://127.0.0.1:5173"; diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..481a58a --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,77 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-bookworm-slim AS build + +WORKDIR /app +ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 + +COPY package.json package-lock.json ./ +COPY apps/server/package.json apps/server/package.json +COPY apps/web/package.json apps/web/package.json +COPY packages/core/package.json packages/core/package.json +COPY packages/renderer/package.json packages/renderer/package.json +RUN npm ci + +COPY tsconfig.base.json ./ +COPY apps ./apps +COPY packages ./packages +RUN npm run build + +FROM node:22-bookworm-slim AS production-dependencies + +WORKDIR /app +ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 + +COPY package.json package-lock.json ./ +COPY apps/server/package.json apps/server/package.json +COPY apps/web/package.json apps/web/package.json +COPY packages/core/package.json packages/core/package.json +COPY packages/renderer/package.json packages/renderer/package.json +RUN npm ci --omit=dev --workspace @md-to-pdf/server --include-workspace-root + +FROM node:22-bookworm-slim AS runtime + +USER root +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + nginx \ + fonts-noto-cjk \ + fonts-noto-color-emoji \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system pwuser \ + && useradd --system --create-home --gid pwuser --shell /bin/bash pwuser + +WORKDIR /app + +COPY --chown=pwuser:pwuser package.json package-lock.json ./ +COPY --chown=pwuser:pwuser apps/server/package.json apps/server/package.json +COPY --chown=pwuser:pwuser apps/web/package.json apps/web/package.json +COPY --chown=pwuser:pwuser packages/core/package.json packages/core/package.json +COPY --chown=pwuser:pwuser packages/renderer/package.json packages/renderer/package.json +COPY --chown=pwuser:pwuser --from=production-dependencies /app/node_modules ./node_modules +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +RUN npx playwright install --with-deps chromium \ + && chown -R pwuser:pwuser /ms-playwright + +COPY --chown=pwuser:pwuser --from=build /app/apps/server/dist ./apps/server/dist +COPY --chown=pwuser:pwuser --from=build /app/apps/web/dist ./apps/web/dist +COPY --chown=pwuser:pwuser --from=build /app/packages/core/dist ./packages/core/dist +COPY --chown=pwuser:pwuser --from=build /app/packages/renderer/dist ./packages/renderer/dist +COPY --chown=pwuser:pwuser themes ./themes +COPY deploy/nginx.conf /etc/nginx/nginx.conf +COPY --chown=pwuser:pwuser deploy/entrypoint.sh /app/deploy/entrypoint.sh + +RUN chmod 0755 /app/deploy/entrypoint.sh + +ENV HOST=127.0.0.1 \ + PORT=3001 \ + PDF_RENDER_ORIGIN=http://127.0.0.1:8080 \ + PDF_CONCURRENCY=1 \ + PDF_MAX_QUEUE=4 \ + PDF_TIMEOUT_MS=120000 + +USER pwuser + +EXPOSE 8080 + +ENTRYPOINT ["/app/deploy/entrypoint.sh"] diff --git a/deploy/Dockerfile.dockerignore b/deploy/Dockerfile.dockerignore new file mode 100644 index 0000000..efc9c8c --- /dev/null +++ b/deploy/Dockerfile.dockerignore @@ -0,0 +1,13 @@ +.git +.github +.local +.vscode +coverage +dist +node_modules +output +playwright-report +test-results +tmp +*.log +*.tsbuildinfo diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..e785311 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,76 @@ +# AIO 容器部署 + +本目录提供单容器部署:Nginx 在 `8080` 端口提供前端,并将 +`/api/` 代理到同容器内的 Fastify;Fastify 复用固定版本 +Playwright Chromium 生成 PDF。 + +## 启动 + +要求已经安装并启动 Docker Desktop。 + +```powershell +docker compose -f deploy\compose.yaml up --build -d +docker compose -f deploy\compose.yaml ps +``` + +打开: + +```text +http://localhost:8080 +``` + +健康检查: + +```powershell +Invoke-RestMethod -Uri 'http://localhost:8080/api/health' +``` + +查看日志和停止: + +```powershell +docker compose -f deploy\compose.yaml logs -f +docker compose -f deploy\compose.yaml down +``` + +## 构建发布标签 + +本地验证完成后,可以为镜像增加发布标签: + +```powershell +docker tag md-to-pdf:local yixiong/md-to-pdf:v0.1.0 +``` + +该命令只创建本地标签,不会自动推送到镜像仓库。 + +## 主题挂载 + +Compose 默认将项目的 `.local/themes` 只读挂载到容器中的 +`/app/.local/themes`。可以通过环境变量指定其他目录: + +```powershell +$env:MD_TO_PDF_THEME_DIR = 'D:\md-to-pdf-themes' +docker compose -f deploy\compose.yaml up -d +``` + +目录下每套主题都应包含 `theme.json`。主题规范详见 +[`docs/THEMES.md`](../docs/THEMES.md)。 + +本机通过导入工具生成的 Typora 主题不会复制进镜像;只有显式挂载后, +容器才会读取这些本机文件。 + +## 运行参数 + +| 环境变量 | 默认值 | 说明 | +| --- | ---: | --- | +| `MD_TO_PDF_PORT` | `8080` | 宿主机监听端口 | +| `MD_TO_PDF_THEME_DIR` | `../.local/themes` | 宿主机主题目录 | +| `PDF_CONCURRENCY` | `1` | 同时执行的 PDF 任务数 | +| `PDF_MAX_QUEUE` | `4` | 等待队列上限 | +| `PDF_TIMEOUT_MS` | `120000` | 单次 PDF 生成超时 | + +增加 PDF 并发会显著增加 Chromium 内存占用。单机部署建议先保持默认值, +再根据文档复杂度和可用内存调整。 + +容器为 Chromium 提供 1 GiB 共享内存,并以非 root `pwuser` 运行。 +构建阶段使用项目锁定的 Playwright `1.62.0` 安装精确匹配的 Chromium +及系统依赖,不依赖版本可能滞后的预制浏览器镜像标签。 diff --git a/deploy/compose.yaml b/deploy/compose.yaml new file mode 100644 index 0000000..dc5c2ec --- /dev/null +++ b/deploy/compose.yaml @@ -0,0 +1,35 @@ +name: md-to-pdf + +services: + app: + build: + context: .. + dockerfile: deploy/Dockerfile + image: md-to-pdf:local + init: true + ports: + - "${MD_TO_PDF_PORT:-8080}:8080" + environment: + HOST: 127.0.0.1 + PORT: 3001 + PDF_RENDER_ORIGIN: http://127.0.0.1:8080 + PDF_CONCURRENCY: "${PDF_CONCURRENCY:-1}" + PDF_MAX_QUEUE: "${PDF_MAX_QUEUE:-4}" + PDF_TIMEOUT_MS: "${PDF_TIMEOUT_MS:-120000}" + volumes: + - "${MD_TO_PDF_THEME_DIR:-../.local/themes}:/app/.local/themes:ro" + shm_size: "1gb" + security_opt: + - no-new-privileges:true + restart: unless-stopped + stop_grace_period: 30s + healthcheck: + test: + - CMD + - node + - -e + - fetch('http://127.0.0.1:8080/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1)) + interval: 10s + timeout: 3s + retries: 6 + start_period: 30s diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh new file mode 100644 index 0000000..91d73d2 --- /dev/null +++ b/deploy/entrypoint.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +server_pid="" +nginx_pid="" + +terminate() { + trap - TERM INT + if [[ -n "${nginx_pid}" ]]; then + kill -TERM "${nginx_pid}" 2>/dev/null || true + fi + if [[ -n "${server_pid}" ]]; then + kill -TERM "${server_pid}" 2>/dev/null || true + fi +} + +trap terminate TERM INT + +node /app/apps/server/dist/index.js & +server_pid=$! + +nginx -g "daemon off;" & +nginx_pid=$! + +set +e +wait -n "${server_pid}" "${nginx_pid}" +status=$? +set -e + +terminate +wait "${server_pid}" 2>/dev/null || true +wait "${nginx_pid}" 2>/dev/null || true +exit "${status}" diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..a2c3f4d --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,50 @@ +worker_processes auto; +pid /tmp/nginx.pid; + +error_log /dev/stderr warn; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + types { + application/javascript mjs; + } + default_type application/octet-stream; + + access_log /dev/stdout; + sendfile on; + keepalive_timeout 65; + client_max_body_size 3m; + + client_body_temp_path /tmp/nginx-client-body; + proxy_temp_path /tmp/nginx-proxy; + fastcgi_temp_path /tmp/nginx-fastcgi; + uwsgi_temp_path /tmp/nginx-uwsgi; + scgi_temp_path /tmp/nginx-scgi; + + server { + listen 8080; + server_name _; + root /app/apps/web/dist; + + location /api/ { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 5s; + proxy_read_timeout 130s; + proxy_send_timeout 130s; + proxy_buffering off; + } + + location / { + try_files $uri $uri/ /index.html; + } + } +} diff --git a/docker/README.md b/docker/README.md deleted file mode 100644 index f8aef33..0000000 --- a/docker/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Docker - -本目录将在 PDF 渲染阶段加入基于固定版本 Chromium 的容器配置。 - -计划包括: - -- 非 root 运行用户; -- Chromium 运行依赖; -- 临时目录容量限制; -- 健康检查; -- Docker Compose 内网部署; -- 请求结束后的临时资源清理。 diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 2bb0e91..224d651 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -19,6 +19,7 @@ main 已有提交: ```text +d1b5880 feat: 实现 Chromium PDF 导出与精确预览 d625523 fix: 修复长文档分页与滚动同步 1d93f31 feat: 实现真实分页预览 52cf816 feat: 完善导出设置与打印预览 @@ -177,6 +178,18 @@ ec48bce chore: 初始化项目骨架 - 65 页附件的主要耗时来自 Mermaid 渲染、Paged.js 分页和 Chromium PDF 打印。 - PDF 缓冲区直接返回,避免无意义的二次 `Buffer` 复制。 +### 3.5 AIO 容器部署 + +- 使用多阶段 Dockerfile 构建前端、后端和共享包。 +- 基于 Node.js 22 Debian 构建运行镜像,并由项目锁定的 Playwright + `1.62.0` 安装精确匹配的 Chromium 与系统依赖。 +- 单容器内由 Nginx 提供前端并代理 Fastify API。 +- Playwright 通过同一 Nginx 地址加载精确预览运行时。 +- 容器以非 root `pwuser` 运行并提供 1 GiB 共享内存。 +- Compose 支持端口、只读主题目录、PDF 并发、队列和超时配置。 +- 增加容器健康检查和统一进程退出处理。 +- Nginx 显式以 JavaScript MIME 类型提供 PDF.js `.mjs` Worker。 + ## 4. 已执行验证 2026-07-26 在当前完整工作区成功执行: @@ -218,11 +231,26 @@ git diff --check - KaTeX、代码高亮和 Mermaid 正常显示; - 浏览器控制台无警告或错误。 -为方便用户继续检查,开发服务当前仍在后台运行,前端监听 5173,后端监听 3001。 +2026-07-26 已在 Docker Desktop 完成 AIO 容器验证: + +- Docker Compose 配置展开、镜像构建、启动、健康检查和重启恢复通过; +- 运行镜像约 611 MB,Node.js、Nginx、Fastify 和 Chromium 均以非 + root UID 999 运行; +- Nginx 首页、分页运行页、API、主题 CSS 和 PDF.js Worker 均正常; +- 默认只读挂载识别 1 个内置主题和 3 个本地主题; +- 周报通过容器导出为 4 页,附件通过容器导出为 65 页,Mermaid + 错误数均为 0; +- 两份 PDF 的所有页面均包含可搜索文字,附件共提取 68,434 个字符; +- 抽查周报第 1、4 页和附件第 1、33、65 页,未发现裁切、重叠、 + 黑块或空白页; +- 浏览器快速预览、主题切换、精确 PDF.js 预览和导出交互通过; +- 修复 Nginx 缺少 `.mjs` MIME 映射导致精确预览 Worker 加载失败的问题。 + +为方便用户继续检查,AIO 容器当前运行在 `http://localhost:8080`。 ## 5. 当前注意事项 -- `output/` 可能包含本地 PDF 验证产物,不得提交。 +- `output/` 可能包含本地 PDF 验证产物,已被 Git 忽略,不得提交。 - `apps/web/src/App.tsx` 负责分页 iframe 生命周期和父页面消息处理。 - `apps/web/src/paged-document-runtime.ts` 是快速预览和 PDF 共用的 Mermaid、资源等待和 Paged.js 分页运行时。 - `apps/web/src/paged-preview-frame.ts` 负责 iframe 消息协议和运行目标选择。 @@ -241,7 +269,7 @@ git diff --check - `.local/theme-backups` 中保留 GitHub、Pixyll 和 Whitey 的可恢复备份;Newsprint 和 Night 的当前副本及备份已按用户要求删除。 - Typora 官方资源是 All Rights Reserved,仅限当前用户本机使用,不进入 Git、容器或发布包。 - 资源目录上传、本地相对图片、临时目录和路径安全尚未实现。 -- Dockerfile 和 Compose 尚未实现。 +- AIO 容器已经通过 Docker Desktop 构建、端到端和浏览器验证。 ## 6. 推荐接手顺序 @@ -260,17 +288,6 @@ git diff --check - 浏览器本地配置预设; - 配置 JSON 导入和导出。 -### 阶段三:容器化与交付 - -- Dockerfile; -- Docker Compose; -- Chromium 字体与中文字体; -- 非 root 用户; -- 健康检查; -- 临时目录容量限制; -- 内网部署说明; -- 完整 PDF 样例和视觉回归验证。 - ## 7. 首版验收目标 - 可以选择或粘贴 Markdown;