diff --git a/apps/web/src/features/resume/editor/check/check-panel.tsx b/apps/web/src/features/resume/editor/check/check-panel.tsx index 4ce95c7e6..bdfd2702c 100644 --- a/apps/web/src/features/resume/editor/check/check-panel.tsx +++ b/apps/web/src/features/resume/editor/check/check-panel.tsx @@ -1,5 +1,4 @@ import type { AtsCategory, AtsReport } from "@reactive-resume/resume/ats"; -import type { PdfAtsReport } from "@reactive-resume/resume/ats-pdf"; import type { ResumeData } from "@reactive-resume/schema/resume/data"; import type { CSSProperties } from "react"; import type { CheckTab } from "../store"; @@ -324,11 +323,14 @@ function CategoryRows({ report }: { report: AtsReport }) { /** * Also check the exported PDF: renders it, runs the file-level engine on it in this tab and reports in a toast. - * Its full report opens from the toast. + * What it found is pinned to the page, and its full report opens from the toast or a pin. */ function DeepCheck({ data }: { data: ResumeData }) { const [running, setRunning] = useState(false); - const [report, setReport] = useState(null); + const report = useEditorStore((state) => state.exportCheck?.report ?? null); + const reportOpen = useEditorStore((state) => state.exportReportOpen); + const setExportCheck = useEditorStore((state) => state.setExportCheck); + const setReportOpen = useEditorStore((state) => state.setExportReportOpen); const run = async () => { setRunning(true); @@ -336,13 +338,14 @@ function DeepCheck({ data }: { data: ResumeData }) { const blob = await createResumePdfBlob(data); const result = await runAtsCheck(blobToPdfFile(blob, "resume.pdf")); const problems = result.report.findings.length; + setExportCheck({ report: result.report, data }); toast.add( problems === 0 ? { description: t`Exported PDF checked: it reads cleanly too.` } : { description: t`Exported PDF checked: ${problems} more to look at.`, - actionProps: { children: t`Show`, onClick: () => setReport(result.report) }, + actionProps: { children: t`Show`, onClick: () => setReportOpen(true) }, }, ); } catch { @@ -359,7 +362,7 @@ function DeepCheck({ data }: { data: ResumeData }) { {running ? Checking the exported PDF… : Also check the exported PDF} - !open && setReport(null)}> + diff --git a/apps/web/src/features/resume/editor/check/page-layer.test.tsx b/apps/web/src/features/resume/editor/check/page-layer.test.tsx new file mode 100644 index 000000000..f0f2a23a7 --- /dev/null +++ b/apps/web/src/features/resume/editor/check/page-layer.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment happy-dom +import type { PdfAtsReport, PdfFinding } from "@reactive-resume/resume/ats-pdf"; +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { i18n } from "@lingui/core"; +import { I18nProvider } from "@lingui/react"; +import { parseResumeData } from "@reactive-resume/schema/resume/data"; +import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; +import { useEditorStore } from "../store"; +import { CheckPageLayer } from "./page-layer"; + +const current = vi.hoisted(() => ({ data: undefined as unknown })); + +vi.mock("./use-check", () => ({ + useCheck: () => ({ data: current.data, report: { findings: [] }, issues: [] }), +})); +vi.mock("./actions", () => ({ scrollToIssue: vi.fn(), useCheckActions: () => ({ fix: vi.fn() }) })); + +beforeAll(() => { + i18n.loadAndActivate({ locale: "en-US", messages: {} }); +}); + +afterEach(() => { + cleanup(); + useEditorStore.getState().reset(); +}); + +const finding = (page: number): PdfFinding => ({ + code: "REVERSED_DATE_RANGE", + severity: "warning", + category: "dates", + evidence: { snippet: "2024 – 2019", page, box: { x: 60, y: 200, width: 120, height: 12 } }, +}); + +const report = (findings: PdfFinding[]) => ({ findings, tips: [] }) as unknown as PdfAtsReport; + +const renderLayer = (pageIndex = 0) => + render( + +
+ +
+
, + ); + +describe("CheckPageLayer: the deep check's findings", () => { + it("pins a finding where the exported PDF shows it, and opens the report from the pin", () => { + const data: ResumeData = parseResumeData(structuredClone(sampleResumeData)); + current.data = data; + useEditorStore.getState().setExportCheck({ report: report([finding(1)]), data }); + renderLayer(); + + const pin = screen.getByRole("button", { name: /^Exported PDF: / }); + expect(pin.style.top).toContain(`${(200 / 800) * 100}%`); + + fireEvent.click(pin); + expect(useEditorStore.getState().exportReportOpen).toBe(true); + }); + + it("pins nothing on other pages, or once the resume has changed since the check ran", () => { + const data: ResumeData = parseResumeData(structuredClone(sampleResumeData)); + current.data = data; + useEditorStore.getState().setExportCheck({ report: report([finding(2)]), data }); + renderLayer(0); + expect(screen.queryByRole("button", { name: /^Exported PDF: / })).toBeNull(); + cleanup(); + + useEditorStore.getState().setExportCheck({ report: report([finding(1)]), data }); + current.data = structuredClone(data); + renderLayer(0); + expect(screen.queryByRole("button", { name: /^Exported PDF: / })).toBeNull(); + }); +}); diff --git a/apps/web/src/features/resume/editor/check/page-layer.tsx b/apps/web/src/features/resume/editor/check/page-layer.tsx index 8752e4ee4..c7bdb703c 100644 --- a/apps/web/src/features/resume/editor/check/page-layer.tsx +++ b/apps/web/src/features/resume/editor/check/page-layer.tsx @@ -10,6 +10,7 @@ import { Button } from "@reactive-resume/ui/components/button"; import { Icon } from "@reactive-resume/ui/components/icon"; import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint"; import { cn } from "@reactive-resume/utils/style"; +import { getPdfFindingMessage } from "@/features/ats-checker/messages"; import { isSameSelection, useEditorStore } from "../store"; import { findEntry } from "../write/model"; import { scrollToIssue, useCheckActions } from "./actions"; @@ -29,13 +30,15 @@ const findNode = (pageMap: PageMap, target: PageMapTarget) => * A marker in the page margin, level with the top of a block: 30px left of it, but never off the page, whose * margins can be narrower than the marker. `stack` moves further markers on the same block down. */ -const marginStyle = (node: PageMapNode, page: { width: number; height: number }, stack = 0): CSSProperties => ({ +type Box = Pick; + +const marginStyle = (node: Box, page: { width: number; height: number }, stack = 0): CSSProperties => ({ left: `max(4px, calc(${(node.x / page.width) * 100}% - 30px))`, top: `calc(${(node.y / page.height) * 100}% - 3px + ${stack * 26}px)`, }); /** Page-relative placement of a block, as percentages of its page. */ -function boxStyle(node: PageMapNode, page: { width: number; height: number }): CSSProperties { +function boxStyle(node: Box, page: { width: number; height: number }): CSSProperties { return { left: `${(node.x / page.width) * 100}%`, top: `${(node.y / page.height) * 100}%`, @@ -87,6 +90,8 @@ export function CheckPageLayer({ pageIndex, pageMap }: CheckPageLayerProps) { const selected = useEditorStore((state) => state.checkIssue); const highlightTerm = useEditorStore((state) => state.highlightTerm); const proposals = useEditorStore((state) => state.proposals); + const exportCheck = useEditorStore((state) => state.exportCheck); + const openExportReport = useEditorStore((state) => state.setExportReportOpen); const breakpoint = useBreakpoint(); const page = pageMap?.pages[pageIndex]; @@ -130,6 +135,16 @@ export function CheckPageLayer({ pageIndex, pageMap }: CheckPageLayerProps) { }) : []; + // The deep check's findings, where it found them in the exported PDF: the same pages as the preview, as long as + // the resume hasn't changed since it ran. + const exportPins = + tab === "issues" && exportCheck?.data === check.data + ? [...exportCheck.report.findings, ...exportCheck.report.tips].flatMap((finding, index) => { + const { page: pageNumber, box } = finding.evidence ?? {}; + return box && pageNumber === pageIndex + 1 ? [{ finding, box, key: `${finding.code}:${index}` }] : []; + }) + : []; + const tinted = highlighted.flatMap((target) => pageMap.nodes.filter((node) => node.page === pageIndex && isSameSelection(toTarget(node), target)), ); @@ -176,6 +191,27 @@ export function CheckPageLayer({ pageIndex, pageMap }: CheckPageLayerProps) { ); })} + {exportPins.map(({ finding, box, key }) => ( +
+ + ))} + {markers.map(({ number, node }) => ( void; setZoom: (zoom: number | "fit") => void; setDrawerOpen: (open: boolean) => void; @@ -112,6 +120,8 @@ type EditorStore = { setProposals: (proposals: readonly Proposal[]) => void; setProposalStatus: (ids: readonly string[], status: Proposal["status"]) => void; setWritingReview: (review: WritingReview | null) => void; + setExportCheck: (check: { report: PdfAtsReport; data: ResumeData } | null) => void; + setExportReportOpen: (open: boolean) => void; reset: () => void; }; @@ -153,6 +163,8 @@ const initialState = { pastedPosting: "", proposals: [], writingReview: null, + exportCheck: null, + exportReportOpen: false, } as const; export const useEditorStore = create()((set) => ({ @@ -196,5 +208,7 @@ export const useEditorStore = create()((set) => ({ proposals: state.proposals.map((proposal) => (ids.includes(proposal.id) ? { ...proposal, status } : proposal)), })), setWritingReview: (writingReview) => set({ writingReview }), + setExportCheck: (exportCheck) => set({ exportCheck }), + setExportReportOpen: (exportReportOpen) => set({ exportReportOpen }), reset: () => set(initialState), })); diff --git a/packages/resume/src/ats-pdf/index.ts b/packages/resume/src/ats-pdf/index.ts index c755c06b2..f3a7b79d8 100644 --- a/packages/resume/src/ats-pdf/index.ts +++ b/packages/resume/src/ats-pdf/index.ts @@ -12,6 +12,7 @@ import { buildResumeSemantics } from "./analyze/semantics"; import { pdfRuleCategory, pdfRuleSeverity } from "./catalog"; import { buildExtractedDocument } from "./extract"; import { matchJobDescription } from "./jd/match"; +import { locateEvidence } from "./locate"; import { PDF_CHECKS } from "./rules"; import { scoreChecks } from "./score"; @@ -90,7 +91,13 @@ export function analyzePdfResume(raw: RawExtraction, options: AnalyzePdfOptions const context: PdfCheckContext = { raw, doc, semantics, now }; const checks = PDF_CHECKS.map((check) => runCheck(check, context)); - const allFindings = checks.flatMap((result) => result.findings).sort(compareFindings); + const allFindings = checks + .flatMap((result) => result.findings) + .map((finding) => { + const evidence = locateEvidence(doc, finding.evidence); + return evidence ? { ...finding, evidence } : finding; + }) + .sort(compareFindings); const breakdown = scoreChecks(checks); const counts: Record = { blocker: 0, warning: 0, tip: 0 }; diff --git a/packages/resume/src/ats-pdf/locate.test.ts b/packages/resume/src/ats-pdf/locate.test.ts new file mode 100644 index 000000000..7e0393c9f --- /dev/null +++ b/packages/resume/src/ats-pdf/locate.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { buildExtractedDocument } from "./extract"; +import { locateEvidence } from "./locate"; +import { healthyResume, healthyResumeLines } from "./test-fixtures"; + +const doc = buildExtractedDocument(healthyResume()); +const lineText = (entry: (typeof healthyResumeLines)[number]) => (typeof entry === "string" ? entry : entry.text); + +describe("locateEvidence", () => { + it("finds the line that holds a snippet and gives its page and box", () => { + const target = doc.lines[3]; + if (!target) throw new Error("fixture has too few lines"); + + const located = locateEvidence(doc, { snippet: target.text }); + + expect(located).toEqual({ + snippet: target.text, + page: target.page, + box: { x: target.x, y: target.y, width: target.width, height: target.height }, + }); + }); + + it("finds a cut snippet by its start, ignoring case and spacing", () => { + const text = lineText(healthyResumeLines[3] ?? ""); + const cut = `${text.slice(0, 12).toUpperCase().replace(" ", " ")}…`; + + expect(locateEvidence(doc, { snippet: cut })?.box).toBeDefined(); + }); + + it("keeps a rule's own box, and leaves evidence it can't place as it was", () => { + const box = { x: 1, y: 2, width: 3, height: 4 }; + expect(locateEvidence(doc, { page: 1, box })).toEqual({ page: 1, box }); + expect(locateEvidence(doc, { snippet: "nowhere in this resume" })).toEqual({ snippet: "nowhere in this resume" }); + expect(locateEvidence(doc, { snippet: lineText(healthyResumeLines[3] ?? ""), page: 9 })?.box).toBeUndefined(); + expect(locateEvidence(doc, undefined)).toBeUndefined(); + }); +}); diff --git a/packages/resume/src/ats-pdf/locate.ts b/packages/resume/src/ats-pdf/locate.ts new file mode 100644 index 000000000..f046e2047 --- /dev/null +++ b/packages/resume/src/ats-pdf/locate.ts @@ -0,0 +1,22 @@ +import type { ExtractedDocument, PdfEvidence } from "./types"; + +const normalize = (text: string) => text.replace(/\s+/g, " ").trim().toLowerCase(); + +/** + * Puts a finding's evidence on the page: its own box when a rule gave one, or else the line whose text holds its + * snippet (on the finding's page, when it names one). Pages are 1-based; boxes are top-left points. + */ +export function locateEvidence(doc: ExtractedDocument, evidence: PdfEvidence | undefined): PdfEvidence | undefined { + if (!evidence || evidence.box || !evidence.snippet) return evidence; + // Long snippets are cut with an ellipsis, and may run over several lines: the start is enough to find them. + const needle = normalize(evidence.snippet.replace(/…$/, "")).slice(0, 60); + if (!needle) return evidence; + + const candidates = evidence.page === undefined ? doc.lines : doc.lines.filter((line) => line.page === evidence.page); + const line = + candidates.find((candidate) => normalize(candidate.text).includes(needle)) ?? + candidates.find((candidate) => needle.startsWith(normalize(candidate.text)) && candidate.text.trim().length >= 8); + if (!line) return evidence; + + return { ...evidence, page: line.page, box: { x: line.x, y: line.y, width: line.width, height: line.height } }; +}