Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 6x 6x 6x 6x 6x 6x 6x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 12x 12x 12x 34x 34x 6x 6x 1x 7x 7x 7x 7x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 4x 4x 4x 4x 4x 2x 4x 4x 2x 2x 2x 2x 2x 2x 2x 2x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 8x 8x 1x 1x 1x 1x 1x 7x 7x 8x 1x 1x 1x 1x 1x 1x 1x 2x 2x 2x | /**
* Document-upload extraction (contracts/analyze-document.md steps 1–3,
* research.md R4/R6): inline magic-byte sniff, 10 MB boundary, mammoth
* (.docx) / unpdf (.pdf) extraction, encrypted/image-only/corrupt
* detection. Every rejection here happens BEFORE metering (research R7) —
* callers must not call checkAndIncrement until extractDocument resolves.
*/
export type DocumentRejectionCode =
| "FILE_TOO_LARGE"
| "UNSUPPORTED_FILE_TYPE"
| "FILE_PASSWORD_PROTECTED"
| "FILE_UNREADABLE"
| "FILE_NO_TEXT";
export class DocumentRejectedError extends Error {
readonly code: DocumentRejectionCode;
constructor(code: DocumentRejectionCode, message: string) {
super(message);
this.name = "DocumentRejectedError";
this.code = code;
}
}
export const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024;
type DocumentType = "pdf" | "docx";
const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46]; // %PDF
const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04]; // PK\x03\x04 (ZIP/OOXML)
// OLE2/Compound File header — the standard signature Office writes for a
// password-protected (Agile-encrypted) .docx, which is not a ZIP at all.
const OLE2_MAGIC = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
function startsWith(bytes: Buffer, magic: number[]): boolean {
if (bytes.length < magic.length) return false;
for (let i = 0; i < magic.length; i++) {
if (bytes[i] !== magic[i]) return false;
}
return true;
}
function sniffType(bytes: Buffer): DocumentType {
if (startsWith(bytes, PDF_MAGIC)) return "pdf";
if (startsWith(bytes, ZIP_MAGIC)) return "docx";
if (startsWith(bytes, OLE2_MAGIC)) {
throw new DocumentRejectedError(
"FILE_PASSWORD_PROTECTED",
"This document is password-protected. Remove the password and try again."
);
}
throw new DocumentRejectedError(
"UNSUPPORTED_FILE_TYPE",
"Only .docx and .pdf files are supported."
);
}
async function extractPdfText(bytes: Buffer): Promise<string> {
const { extractText, getDocumentProxy } = await import("unpdf");
let text: string;
try {
const pdf = await getDocumentProxy(new Uint8Array(bytes));
const result = await extractText(pdf, { mergePages: true });
text = Array.isArray(result.text) ? result.text.join("\n") : result.text;
} catch (err) {
if (err instanceof Error && err.name === "PasswordException") {
throw new DocumentRejectedError(
"FILE_PASSWORD_PROTECTED",
"This document is password-protected. Remove the password and try again."
);
}
throw new DocumentRejectedError(
"FILE_UNREADABLE",
"This PDF could not be read. It may be corrupt."
);
}
return text;
}
async function extractDocxText(bytes: Buffer): Promise<string> {
const mammoth = await import("mammoth");
try {
const result = await mammoth.extractRawText({ buffer: bytes });
return result.value;
} catch {
throw new DocumentRejectedError(
"FILE_UNREADABLE",
"This document could not be read. It may be corrupt."
);
}
}
export interface ExtractionResult {
text: string;
type: DocumentType;
}
/**
* Ordered per the contract: size → sniff → extract → empty-text check.
* Every failure path throws DocumentRejectedError before any allowance is
* touched (SC-005) — callers must not have called checkAndIncrement yet.
*/
export async function extractDocument(bytes: Buffer): Promise<ExtractionResult> {
if (bytes.length > MAX_DOCUMENT_BYTES) {
throw new DocumentRejectedError(
"FILE_TOO_LARGE",
`File exceeds the ${(MAX_DOCUMENT_BYTES / (1024 * 1024)).toFixed(0)} MB limit.`
);
}
const type = sniffType(bytes);
const text = type === "pdf" ? await extractPdfText(bytes) : await extractDocxText(bytes);
if (text.trim().length === 0) {
throw new DocumentRejectedError(
"FILE_NO_TEXT",
"No text could be extracted from this document. If it's a scanned image, OCR is not yet supported."
);
}
return { text, type };
}
|