简历 composer:一个渲染器画画布也画 PDF

更新于 · 在 sijie.xyz 查看原条目 ↗

父级:key-designs · 兄弟:embed-credential-never-carries-the-code

结论(2026-09-07): 简历只有一个渲染器——app/src/lib/admin/resume-puck-config.tsx 里的 Puck 组件配置。owner 在它上面编辑(/admin/edit-resume/[id] 的画布),而 PDF(Portable Document Format)就是同一份配置在打印路由上由 Puck 的 <Render> 渲染、再由 gotenberg 的 Chromium 打印出来。2026-09-01 上线的 Typst 渲染器(340ffabf9)在 2026-09-07 被移除(1b1ebed2b),因为两个渲染器就是两份真相:owner 看到的不是招聘者拿到的。规范文档仍是 resume_content;Puck 数据是它的投影,有往返测试。

触发问题。 简历是 chain-hiring 的出站那一半:PDF 上印着一个 QR(quick-response)码,它的 URL 让招聘者凭 access code 进门(access-control)。招聘者评判的是 PDF,从来不是编辑器。Typst 画 PDF、React 画画布的时候,每条排版规则都存在两份,保真就变成靠手把两套实现对齐——这类缺陷没有测试能穷举。一个渲染器让"你看到的"和"他们拿到的"走同一条代码路径。

草稿从哪来

  • 来自 job loop。 MCP 工具 resume.draft(backend/internal/owner/jobs/jobsmcp/cap_resume.go:67 → jobsuc.DraftResume,jobsuc/resume.go:51)从 Redis 池子取一个 job,写一行 resume_drafts(backend/db/schema.sql:891),装着 job 快照和 Claude 编排的内容。
  • 手动。 POST /api/admin/drafts(jobsadmin/routes.go:71 → jobsuc.CreateManualDraft,resume.go:89)从 owner 的资料生成一份草稿(seedResumeContent,resume.go:115),不挂 job。2026-09-05 上线(c611152b3)。
  • 两条路落在同一张表、开在同一个编辑器里;草稿就是草稿。

composer

  • 路由。 app/src/app/admin/edit-resume/[id]/page.tsx 挂载 PuckComposer(动作栏,app/src/components/admin/composer/PuckComposer.tsx),里面包着 PuckResumeEditor。旧 composer、它的 autosave hook、行拖拽和 live-preview spec 在 756c0eb1d 里删掉了(3,557 行)。
  • 投影。 toPuckData(app/src/lib/admin/resume-puck.ts:53)和 fromPuckData(:83)在 ResumeContent ⇄ Puck 数据之间映射。resume-puck.test.ts:42 断言 fromPuckData(toPuckData(rc)) 精确保留每个 section 和每项设置——投影无损,所以 resume_content 可以继续当规范文档。
  • 唯一 id。 toPuckData 给每项盖 props.id = <type>-<index>(resume-puck.ts:64)。Puck 用 props.id 给 zone 做键;id 重复会把所有项塌成一个 section,编辑器抛页面错误(白屏)。2026-09-06 用出来的(0a805488f);守卫是 resume-puck.test.ts:71,108,124 和浏览器 spec resume-composer-sections.spec.ts:33——它数每个 section 各自渲染,并抓 pageerror。
  • 持久化的编辑器状态。 resume_drafts.puck_data jsonb(schema.sql:905),由 migration 2026-09-07-resume-draft-puck-data.sql 用 ADD COLUMN IF NOT EXISTS 加上——可重入,所以从 Puck 之前的版本升上来的实例不用换新卷就有这列(41cb0cd8c)。
  • Save 是显式的。 没有 autosave(PuckResumeEditor.tsx:3-4:编辑器内部什么都不持久化)。PuckComposer.tsx:46-72 通过比较投影后的内容得出 dirty,挂上 beforeunload,并用一个丢弃确认框拦住应用内的返回键;Save 调 savePuckDraft(app/src/lib/admin/save-draft.ts:8)。fb26c1c4d、75898e102。

那张纸

画布是一张固定的 A4——.sm-resume-paper w-[794px] max-w-full aspect-[210/297](resume-puck-config.tsx:153)——有自己的浅色调色板,在 app/src/app/sm-atoms.css:1529,独立于 admin 的日/夜主题。打印分支(:143)去掉纵横比盒子,让 @page 自己流。resume-composer-mobile-a4.spec.ts:30 读 computed aspect-ratio,在手机视口上断言 210 / 297(5826d44e5、eb07cb42c、5f3ee57d1)。

一份配置,两种产物

sequenceDiagram
  participant O as owner (admin)
  participant B as backend
  participant R as redis printsess
  participant G as gotenberg (Chromium)
  participant A as app print route
  O->>B: applications.commit / preview.pdf
  B->>R: put payload under token, expires in 60s
  B->>G: convert URL /print/application/id?t=token
  G->>A: load /print/application/id?t=token
  A->>B: take /internal/print-session/token (one-shot)
  B-->>A: resume_content + qrURL
  A-->>G: Puck Render(resumePuckConfig, print=true)
  G-->>B: PDF bytes
  B-->>O: PDF (commit result / streamed preview)
  • backend/cmd/server/boot_pdf.go:72 拼出 <printBase>/print/application/<id>?t=<token>;app/src/app/print/application/[id]/page.tsx(server component,force-dynamic)渲染 ResumePuckRender = Puck 的 <Render config={resumePuckConfig} metadata={{ qrURL, print: true }}>。
  • 打印会话在 Redis 里活 60 s(backend/internal/owner/jobs/printsess/store.go:32),由 GET /internal/print-session/{token} 一次性消费(routes/sys/print_session.go:32)——一次性 token,所以打印路由不是一个公开的草稿阅读器。
  • 两个调用方,一个渲染器:applications.commit 在不可逆的 commit 之前渲染 PDF(jobsuc/applications.go:121-144),composer 的 preview.pdf 直接流式返回(jobsadmin/drafts_edit.go:114-128)。

二维码

Header 组件画 <QRCode value={qrURL} size={46} />(resume-puck-config.tsx:189,组件在 atoms/QRCode.tsx)。code 选择器 HeaderCodeField(:99-110)是 Header 面板的一个自定义字段,零个 code 时也在(composer-code-picker.spec.ts:28)。URL 是 BuildQRURL = <public_url>/?code=<code>(jobsuc/applications.go:320-322,resume-qr-host.spec.ts:44-46 断言):二维码带的是 code 明文,因为没有会话的招聘者需要一个凭据;落地页随后把它吸收成 /c/<slug>(coded-landing-and-code-rotation)。编辑器里二维码是一个占位框(:190);只有 metadata.qrURL 设了才画真图,也就是打印时。没选 code 的草稿预览编码的是哨兵值 preview://standmeet/draft(drafts_edit.go:28)。

曾经是 bug 的渲染细节

  • CJK(中日韩)字形来自 gotenberg 边车里 Chromium 的字体(backend/Dockerfile:107-108);composer-cjk-renders.spec.ts:40 读已 commit 的 PDF 文本层,NFKC 归一化(兼容正规形式)后期望看到这些字形。dac85c336 最初是在 Typst 字体回退里修的;守卫活得比渲染器久。
  • 自定义 section(CustomProps,resume-puck-config.tsx:49,组件 :299,分隔线类型 :314)——6a9127155。
  • 时间段是两个字段——period: { start, end },end 可空(resume-puck.ts:132),显示时拼接(resume-puck-config.tsx:65)——f4e764361。
  • 没有页脚。 c49a1b4ec 拿掉了页脚;Puck 配置里没有任何页脚或页码渲染。

测试——保真规则

渲染产物、读 PDF 的文本层;绝不拿编辑器的 DOM(Document Object Model)当 PDF 的证明。读取器是 e2e/fixtures/pdf-inspect.ts:20(inspectPDF,pdf-parse 的 getText())。

  • composer-pdf-fidelity.spec.ts:93——每个字段里的哨兵都到达 PDF 文本;两种时间段形式;CJK;没有 undefined 或 [object Object];空草稿不打出孤儿标题(:112)。
  • composer-cjk-renders.spec.ts:40;resume-pdf-render.spec.ts:76,121,139(页数、US-Letter、求职信分页);draft-puck-section-order.spec.ts(section 顺序活到 PDF 里)。
  • 画布侧:resume-composer-mobile-a4.spec.ts、resume-composer-sections.spec.ts、resume-composer-fidelity.spec.ts:29、composer-qr-and-chrome.spec.ts:27、composer-code-picker.spec.ts:28;resume-look.spec.ts:27 渲染 preview.pdf 给人看(只查状态码)。

诚实的天花板

  • "一个渲染器"对 PDF 路径成立,对每张缩略图不成立。 app/src/components/admin/resume-page/ResumePage.tsx 还在画草稿缩略图(DraftThumb.tsx:37)和申请详情弹窗(ApplicationDetailModal.tsx:208)。它不喂任何 PDF;退役它是剩下的一步。
  • 打印路由读的是 resume_content,不是 puck_data。 ResumePuckRender 用 toPuckData 重新推导文档;两者一致只因为投影能往返。Puck 存了而投影丢掉的东西不会打印出来——往返测试就是守卫。
  • Typst 残留。 template 列(schema.sql:906)和 GET /drafts/templates(routes.go:60,74)还在,列表是空的(boot_deps.go:241);.gitignore:48-50 仍忽略 app/public/typst/。死重量,不是第二条路径。
  • 二维码是印在纸上的明文凭据。泄露恢复靠 codes.rotate(coded-landing-and-code-rotation),它一次杀掉所有已打印的副本。

已实现 2026-09-01 → 2026-09-07。 Puck composer 397bb98b0、1e7b2b863;旧版退役 756c0eb1d;投影 id 0a805488f;puck_data 41cb0cd8c + migration 2026-09-07-resume-draft-puck-data.sql;Save 语义 fb26c1c4d、75898e102;纸 5826d44e5、eb07cb42c、5f3ee57d1;Typst 进 340ffabf9、出 1b1ebed2b(backend/cmd/server/boot_pdf.go、backend/internal/owner/jobs/printsess/store.go、app/src/app/print/application/[id]/page.tsx);二维码 + 选择器 3f164dc76、e41db95b6、93954b0f3;手动草稿 c611152b3。相关:ephemeral-over-stateful(打印会话是 60 s 一次性的,job 池是 1 天的池子;只有 application 行是持久的)和 builder-claim-skip-locked(另一处 worker 认领一次性行的地方)。

相关笔记