2026-09-23·by Sijie Wang#software#project#standmeet

resume-composer-one-renderer

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

父级: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.draftbackend/internal/owner/jobs/jobsmcp/cap_resume.go:67jobsuc.DraftResumejobsuc/resume.go:51)从 Redis 池子取一个 job,写一行 resume_draftsbackend/db/schema.sql:891),装着 job 快照和 Claude 编排的内容。
  • 手动。 POST /api/admin/draftsjobsadmin/routes.go:71jobsuc.CreateManualDraftresume.go:89)从 owner 的资料生成一份草稿(seedResumeContentresume.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 行)。
  • 投影。 toPuckDataapp/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 jsonbschema.sql:905),由 migration 2026-09-07-resume-draft-puck-data.sqlADD COLUMN IF NOT EXISTS 加上——可重入,所以从 Puck 之前的版本升上来的实例不用换新卷就有这列(41cb0cd8c)。
  • Save 是显式的。 没有 autosave(PuckResumeEditor.tsx:3-4:编辑器内部什么都不持久化)。PuckComposer.tsx:46-72 通过比较投影后的内容得出 dirty,挂上 beforeunload,并用一个丢弃确认框拦住应用内的返回键;Save 调 savePuckDraftapp/src/lib/admin/save-draft.ts:8)。fb26c1c4d75898e102

那张纸

画布是一张固定的 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 / 2975826d44e5eb07cb42c5f3ee57d1)。

一份配置,两种产物

  • 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-322resume-qr-host.spec.ts:44-46 断言):二维码带的是 code 明文,因为没有会话的招聘者需要一个凭据;落地页随后把它吸收成 /c/<slug>coded-landing-and-code-rotation)。编辑器里二维码是一个占位框(:190);只有 metadata.qrURL 设了才画真图,也就是打印时。没选 code 的草稿预览编码的是哨兵值 preview://standmeet/draftdrafts_edit.go:28)。

曾经是 bug 的渲染细节

  • CJK(中日韩)字形来自 gotenberg 边车里 Chromium 的字体(backend/Dockerfile:107-108);composer-cjk-renders.spec.ts:40 读已 commit 的 PDF 文本层,NFKC 归一化(兼容正规形式)后期望看到这些字形。dac85c336 最初是在 Typst 字体回退里修的;守卫活得比渲染器久。
  • 自定义 sectionCustomPropsresume-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:20inspectPDF,pdf-parse 的 getText())。

  • composer-pdf-fidelity.spec.ts:93——每个字段里的哨兵都到达 PDF 文本;两种时间段形式;CJK;没有 undefined[object Object];空草稿不打出孤儿标题(:112)。
  • composer-cjk-renders.spec.ts:40resume-pdf-render.spec.ts:76,121,139(页数、US-Letter、求职信分页);draft-puck-section-order.spec.ts(section 顺序活到 PDF 里)。
  • 画布侧:resume-composer-mobile-a4.spec.tsresume-composer-sections.spec.tsresume-composer-fidelity.spec.ts:29composer-qr-and-chrome.spec.ts:27composer-code-picker.spec.ts:28resume-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 ResumePuckRendertoPuckData 重新推导文档;两者一致只因为投影能往返。Puck 存了而投影丢掉的东西不会打印出来——往返测试就是守卫。
  • Typst 残留。 template 列(schema.sql:906)和 GET /drafts/templatesroutes.go:60,74)还在,列表是空的(boot_deps.go:241);.gitignore:48-50 仍忽略 app/public/typst/。死重量,不是第二条路径。
  • 二维码是印在纸上的明文凭据。泄露恢复靠 codes.rotatecoded-landing-and-code-rotation),它一次杀掉所有已打印的副本。

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

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →