Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Many useful spreadsheets are trapped inside screenshots: KakaoTalk settlement me

Screenshot-to-Spreadsheet focuses on the review workflow, not blind automation: extract locally, highlight uncertain cells, mask sensitive-looking values, let the user fix the grid, and export CSV/XLSX with a review log.

When sharing bugs or asking for help, use synthetic fixtures or the redacted sample copy action instead of posting raw reviewed values.

![Desktop QA screenshot](docs/qa-desktop-1440x960.png)

## Current MVP
Expand All @@ -20,6 +22,7 @@ Screenshot-to-Spreadsheet focuses on the review workflow, not blind automation:
- Review extracted data in a unified review workspace with the grid, warnings, low-confidence navigation, and export actions in one place.
- Highlight low-confidence and sensitive-looking cells.
- Switch redaction presets between Basic, Finance, and Share sample display modes.
- Copy a redacted tab-separated sample to the clipboard for safer issue reports and discussions.
- Apply browser-local crop/rotate/grayscale/contrast preprocessing before OCR.
- Export CSV and XLSX, including a `review_log` sheet for XLSX.
- Register a production service worker and web app manifest for an offline/PWA app shell.
Expand Down
24 changes: 24 additions & 0 deletions scripts/qa-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ try {
await send("Page.enable");
await send("Runtime.enable");
await send("Network.enable");
await send("Browser.grantPermissions", {
permissions: ["clipboardReadWrite", "clipboardSanitizedWrite"],
origin: appOrigin
});
await send("Browser.setDownloadBehavior", {
behavior: "allow",
downloadPath: downloadDir
Expand Down Expand Up @@ -252,6 +256,25 @@ try {
if (redactionAfter.result.value.firstNameCell === "민수") {
throw new Error("Share sample redaction did not mask the first name cell");
}
await send("Runtime.evaluate", {
expression: `[...document.querySelectorAll('button')].find((button) => /Copy redacted sample|가린 샘플 복사/.test(button.textContent)).click()`
});
await sleep(150);
const clipboardState = await send("Runtime.evaluate", {
awaitPromise: true,
returnByValue: true,
expression: `navigator.clipboard.readText().then((text) => ({
text,
hasMaskedName: /민\\*|M\\*+/.test(text),
includesRawName: text.includes("민수")
}))`
});
if (!clipboardState.result.value.text.includes("\t")) {
throw new Error("Redacted clipboard sample was not copied as tabular text");
}
if (!clipboardState.result.value.hasMaskedName || clipboardState.result.value.includesRawName) {
throw new Error("Redacted clipboard sample did not mask the sample name");
}

await send("Runtime.evaluate", {
expression: `document.querySelectorAll('.mode-button')[2].click()`
Expand Down Expand Up @@ -343,6 +366,7 @@ try {
before: redactionBefore.result.value,
after: redactionAfter.result.value
},
clipboardState: clipboardState.result.value,
csvPath,
xlsxPath,
networkProof: {
Expand Down
20 changes: 19 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { SourceWorkspace } from "./components/SourceWorkspace";
import { ReviewWorkspace } from "./components/ReviewWorkspace";
import { parseTextToGrid } from "./lib/parser";
import { validateGrid } from "./lib/validation";
import { exportCsv, exportXlsx } from "./lib/exporters";
import { copyRedactedSample, exportCsv, exportXlsx } from "./lib/exporters";
import { runOcr } from "./lib/ocr";

const makeReviewRows = (issues: ValidationIssue[]) => [
Expand Down Expand Up @@ -85,6 +85,7 @@ export default function App() {
const [preprocess, setPreprocess] = useState<PreprocessSettings>(defaultPreprocess);
const [reviewCursor, setReviewCursor] = useState(0);
const [activeMobileView, setActiveMobileView] = useState<"setup" | "source" | "review">("setup");
const [exportMessage, setExportMessage] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const copy = uiCopy[uiLanguage];

Expand Down Expand Up @@ -123,6 +124,7 @@ export default function App() {
} else if (text?.trim()) {
setRawText(text);
setGrid(parseTextToGrid(text, mode));
setExportMessage("");
}
};

Expand All @@ -148,6 +150,7 @@ export default function App() {
const runExtractionFromText = (text = rawText) => {
const parsed = parseTextToGrid(text, mode);
setGrid(parsed);
setExportMessage("");
};

const runImageOcr = async () => {
Expand All @@ -170,6 +173,7 @@ export default function App() {
setGrid(parseTextToGrid(result.text, mode));
setOcrStatus(`${copy.ocrComplete} (${Math.round(result.confidence)} confidence)`);
setOcrProgress(100);
setExportMessage("");
} catch (error) {
setOcrStatus(error instanceof Error ? error.message : copy.ocrFailed);
} finally {
Expand All @@ -194,12 +198,14 @@ export default function App() {
setRawText(sample);
setGrid(parseTextToGrid(sample, mode));
setOcrStatus(copy.sampleLoaded);
setExportMessage("");
};

const changeMode = (nextMode: ExtractionMode) => {
setMode(nextMode);
setRawText(sampleTextByMode[nextMode]);
setGrid(parseTextToGrid(sampleTextByMode[nextMode], nextMode));
setExportMessage("");
};

const reset = () => {
Expand All @@ -212,6 +218,16 @@ export default function App() {
setOcrProgress(0);
setRedactionPreset("basic");
setPreprocess(defaultPreprocess);
setExportMessage("");
};

const handleCopyRedactedSample = async () => {
try {
await copyRedactedSample(grid, redactionPreset);
setExportMessage(copy.redactedSampleCopied);
} catch {
setExportMessage(copy.redactedSampleCopyFailed);
}
};

return (
Expand Down Expand Up @@ -314,8 +330,10 @@ export default function App() {
maskSensitive={maskSensitive}
redactionPreset={redactionPreset}
ocrResult={ocrResult}
exportMessage={exportMessage}
onReviewCursorChange={setReviewCursor}
onUpdateCell={updateCell}
onCopyRedactedSample={handleCopyRedactedSample}
onExportCsv={() => exportCsv(grid)}
onExportXlsx={() => exportXlsx(grid, makeReviewRows(issues))}
/>
Expand Down
12 changes: 11 additions & 1 deletion src/components/ReviewWorkspace.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AlertTriangle, Download, Grid3X3, ScanLine } from "lucide-react";
import { AlertTriangle, ClipboardCopy, Download, Grid3X3, ScanLine } from "lucide-react";
import type { DataGrid, GridCell, OcrResult, RedactionPreset, UiLanguage, ValidationIssue } from "../types";
import { uiCopy } from "../data/uiCopy";
import { maskSensitiveValue } from "../lib/validation";
Expand All @@ -19,8 +19,10 @@ type ReviewWorkspaceProps = {
maskSensitive: boolean;
redactionPreset: RedactionPreset;
ocrResult: OcrResult | null;
exportMessage: string;
onReviewCursorChange: (cursor: number) => void;
onUpdateCell: (rowIndex: number, colIndex: number, value: string) => void;
onCopyRedactedSample: () => void;
onExportCsv: () => void;
onExportXlsx: () => void;
};
Expand All @@ -39,8 +41,10 @@ export function ReviewWorkspace({
maskSensitive,
redactionPreset,
ocrResult,
exportMessage,
onReviewCursorChange,
onUpdateCell,
onCopyRedactedSample,
onExportCsv,
onExportXlsx
}: ReviewWorkspaceProps) {
Expand All @@ -58,6 +62,10 @@ export function ReviewWorkspace({
<p>{copy.reviewHint}</p>
</div>
<div className="review-export-actions">
<button className="secondary-button" type="button" onClick={onCopyRedactedSample}>
<ClipboardCopy size={17} />
{copy.copyRedactedSample}
</button>
<button className="primary-button" type="button" onClick={onExportCsv}>
<Download size={17} />
{copy.exportCsv}
Expand All @@ -68,6 +76,8 @@ export function ReviewWorkspace({
</button>
</div>
</div>
<p className="muted-text">{copy.copyRedactedHint}</p>
{exportMessage ? <p className="ok-text">{exportMessage}</p> : null}

<div className="review-summary">
<div className="summary-card">
Expand Down
8 changes: 8 additions & 0 deletions src/data/uiCopy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ export const uiCopy = {
noReviewQueue: "No low-confidence cells",
validation: "Validation",
noWarnings: "No review warnings.",
copyRedactedSample: "Copy redacted sample",
copyRedactedHint: "Share only synthetic or redacted samples in issues and discussions.",
redactedSampleCopied: "Redacted sample copied to clipboard.",
redactedSampleCopyFailed: "Could not copy the redacted sample.",
exportCsv: "Export CSV",
exportXlsx: "Export XLSX",
ocrTokens: "OCR tokens",
Expand Down Expand Up @@ -144,6 +148,10 @@ export const uiCopy = {
noReviewQueue: "확인 필요한 셀이 없습니다",
validation: "검수 알림",
noWarnings: "검토 경고가 없습니다.",
copyRedactedSample: "가린 샘플 복사",
copyRedactedHint: "이슈나 토론에는 합성 데이터나 가린 샘플만 공유하세요.",
redactedSampleCopied: "가린 샘플을 클립보드에 복사했습니다.",
redactedSampleCopyFailed: "가린 샘플을 복사하지 못했습니다.",
exportCsv: "CSV 내보내기",
exportXlsx: "XLSX 내보내기",
ocrTokens: "OCR 토큰",
Expand Down
48 changes: 47 additions & 1 deletion src/lib/exporters.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { DataGrid } from "../types";
import type { DataGrid, RedactionPreset } from "../types";
import { gridToRows } from "./parser";
import { maskSensitiveValue } from "./validation";

const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
Expand All @@ -18,6 +19,51 @@ const escapeCsv = (value: string): string => {
return value;
};

const normalizeClipboardCell = (value: string) => value.replace(/\r?\n/g, " ").trim();

const applyRedactionPreset = (grid: DataGrid, preset: RedactionPreset) => {
const headers = grid[0]?.map((cell) => cell.value) ?? [];

return gridToRows(grid).map((row, rowIndex) =>
row.map((value, colIndex) =>
rowIndex === 0
? normalizeClipboardCell(value)
: normalizeClipboardCell(maskSensitiveValue(value, preset, headers[colIndex] ?? ""))
)
);
};

const buildDelimitedText = (rows: string[][], delimiter: string) =>
rows.map((row) => row.join(delimiter)).join("\n");

const copyWithExecCommand = (text: string) => {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "true");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
document.execCommand("copy");
textarea.remove();
};

export const copyRedactedSample = async (
grid: DataGrid,
preset: RedactionPreset,
delimiter = "\t"
) => {
const text = buildDelimitedText(applyRedactionPreset(grid, preset), delimiter);
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return text;
}

copyWithExecCommand(text);
return text;
};

export const exportCsv = (grid: DataGrid, filename = "screenshot-to-spreadsheet.csv") => {
const csv = gridToRows(grid)
.map((row) => row.map(escapeCsv).join(","))
Expand Down