简历 composer:一个渲染器画画布也画 PDF
结论(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和浏览器 specresume-composer-sections.spec.ts:33——它数每个 section 各自渲染,并抓pageerror。 - 持久化的编辑器状态。
resume_drafts.puck_data jsonb(schema.sql:905),由 migration2026-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)。
一份配置,两种产物
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 认领一次性行的地方)。