mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-02 17:54:22 +10:00
feat: pin the exported PDF's findings to the page in Check
The file-level check places each finding's evidence on its page: the rule's own box, or the line holding its snippet. Check keeps the report with the resume it ran on and, while the resume is unchanged, outlines each finding on the page with a pin that opens the full report.
This commit is contained in:
@@ -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<PdfAtsReport | null>(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 ? <Trans>Checking the exported PDF…</Trans> : <Trans>Also check the exported PDF</Trans>}
|
||||
</Button>
|
||||
|
||||
<Dialog open={report !== null} onOpenChange={(open) => !open && setReport(null)}>
|
||||
<Dialog open={reportOpen && report !== null} onOpenChange={setReportOpen}>
|
||||
<DialogContent className="max-h-[85svh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<div style={{ position: "relative", width: 600, height: 800 }}>
|
||||
<CheckPageLayer pageIndex={pageIndex} pageMap={{ pages: [{ width: 600, height: 800 }], nodes: [] }} />
|
||||
</div>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<PageMapNode, "x" | "y" | "width" | "height">;
|
||||
|
||||
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 }) => (
|
||||
<div key={key}>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute -m-[3px] rounded-[3px] border-[1.5px] border-info-text border-dashed p-[3px]"
|
||||
style={boxStyle(box, page)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-export-pin={finding.code}
|
||||
aria-label={t`Exported PDF: ${getPdfFindingMessage(finding.code).title}`}
|
||||
title={getPdfFindingMessage(finding.code).title}
|
||||
onClick={() => openExportReport(true)}
|
||||
style={marginStyle(box, page)}
|
||||
className="pointer-events-auto absolute grid size-[22px] place-items-center rounded-full border-2 border-white bg-info-text text-bg shadow-[0_1px_3px_oklch(0_0_0/0.25)]"
|
||||
>
|
||||
<Icon name="picture_as_pdf" size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{markers.map(({ number, node }) => (
|
||||
<span
|
||||
key={`marker:${number}`}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { PageMap, PageMapTarget } from "@reactive-resume/pdf/page-map";
|
||||
import type { PdfAtsReport } from "@reactive-resume/resume/ats-pdf";
|
||||
import type { Proposal } from "@reactive-resume/resume/proposals";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import { create } from "zustand/react";
|
||||
|
||||
@@ -85,6 +87,12 @@ type EditorStore = {
|
||||
/** Edits suggested for the page (Check → Writing), shown on it until accepted or rejected. */
|
||||
proposals: readonly Proposal[];
|
||||
writingReview: WritingReview | null;
|
||||
/**
|
||||
* Check's deep check: the report on the exported PDF, and the resume it was run on. Its findings are pinned to
|
||||
* the page only while the resume is unchanged.
|
||||
*/
|
||||
exportCheck: { report: PdfAtsReport; data: ResumeData } | null;
|
||||
exportReportOpen: boolean;
|
||||
select: (selection: EditorSelection | null) => 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<EditorStore>()((set) => ({
|
||||
@@ -196,5 +208,7 @@ export const useEditorStore = create<EditorStore>()((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),
|
||||
}));
|
||||
|
||||
@@ -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<PdfSeverity, number> = { blocker: 0, warning: 0, tip: 0 };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 } };
|
||||
}
|
||||
Reference in New Issue
Block a user