import { OfficeParser, type SupportedFileType } from "officeparser"; import { extractMarkdownFromHtml, markdownFromPlainText } from "./markdown.ts"; export type FetchContentKind = "html" | "text" | "document" | "image" | "binary"; export interface ParsedFetchContent { kind: FetchContentKind; sourceFormat: string; extension: string; charset: string; title: string; sourceText?: string; text?: string; markdown?: string; parseWarning?: string; } export interface ParseFetchContentOptions { mediaType: string; declaredCharset: string; finalUrl: string; contentDisposition?: string; signal?: AbortSignal; } const DOCUMENT_FORMATS = new Set([ "docx", "pptx", "xlsx", "odt", "odp", "ods", "pdf", "rtf", "epub", ]); const DOCUMENT_MIME_TYPES = new Map([ ["application/pdf", "pdf"], ["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "docx"], ["application/vnd.openxmlformats-officedocument.presentationml.presentation", "pptx"], ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx"], ["application/vnd.oasis.opendocument.text", "odt"], ["application/vnd.oasis.opendocument.presentation", "odp"], ["application/vnd.oasis.opendocument.spreadsheet", "ods"], ["application/rtf", "rtf"], ["text/rtf", "rtf"], ["application/epub+zip", "epub"], ]); const TEXT_MIME_FORMATS = new Map([ ["text/html", "html"], ["application/xhtml+xml", "html"], ["text/markdown", "md"], ["text/x-markdown", "md"], ["application/json", "json"], ["application/ld+json", "json"], ["application/xml", "xml"], ["text/xml", "xml"], ["text/csv", "csv"], ["text/tab-separated-values", "tsv"], ["application/yaml", "yaml"], ["text/yaml", "yaml"], ["application/toml", "toml"], ["application/sql", "sql"], ["application/javascript", "js"], ["text/javascript", "js"], ]); const IMAGE_MIME_EXTENSIONS = new Map([ ["image/jpeg", "jpg"], ["image/png", "png"], ["image/gif", "gif"], ["image/webp", "webp"], ["image/bmp", "bmp"], ["image/tiff", "tiff"], ["image/svg+xml", "svg"], ["image/avif", "avif"], ["image/heic", "heic"], ["image/heif", "heif"], ["image/x-icon", "ico"], ]); const TEXT_EXTENSIONS = new Set([ "txt", "md", "markdown", "json", "jsonl", "ndjson", "xml", "rss", "atom", "csv", "tsv", "yaml", "yml", "toml", "ini", "cfg", "conf", "log", "sql", "js", "mjs", "cjs", "ts", "tsx", "jsx", "css", "scss", "less", "py", "rb", "rs", "go", "java", "kt", "kts", "c", "h", "cpp", "hpp", "cs", "swift", "sh", "bash", "zsh", "fish", "ps1", "graphql", "gql", "proto", "tex", "srt", "vtt", ]); const IMAGE_EXTENSIONS = new Set([ "jpg", "jpeg", "png", "gif", "webp", "bmp", "tif", "tiff", "svg", "avif", "heic", "heif", "ico", ]); function safeExtension(value: string | undefined, fallback = "bin"): string { const normalized = value?.toLowerCase().replace(/^\./, ""); return normalized && /^[a-z0-9]{1,12}$/.test(normalized) ? normalized : fallback; } function filenameFromDisposition(value: string | undefined): string | undefined { if (!value) return undefined; const encoded = value.match(/filename\*\s*=\s*UTF-8''([^;]+)/i)?.[1]; if (encoded) { try { return decodeURIComponent(encoded.replace(/^['"]|['"]$/g, "")); } catch { return encoded; } } return value.match(/filename\s*=\s*["']?([^"';]+)["']?/i)?.[1]?.trim(); } function extensionFromName(value: string | undefined): string | undefined { if (!value) return undefined; const clean = value.split(/[?#]/, 1)[0] ?? value; const match = clean.match(/\.([a-z0-9]{1,12})$/i); return match?.[1]?.toLowerCase(); } function sourceExtension(options: ParseFetchContentOptions): string | undefined { const dispositionName = filenameFromDisposition(options.contentDisposition); if (dispositionName) return extensionFromName(dispositionName); try { return extensionFromName(new URL(options.finalUrl).pathname); } catch { return undefined; } } function titleFromUrl(url: string): string { try { const parsed = new URL(url); return decodeURIComponent(parsed.pathname.split("/").filter(Boolean).at(-1) || parsed.hostname); } catch { return "download"; } } function sniffCharset(raw: Uint8Array, declared: string): string { if (declared !== "utf-8") return declared; const prefix = Buffer.from(raw.subarray(0, Math.min(raw.byteLength, 4_096))).toString("latin1"); const match = prefix.match(/]+charset\s*=\s*["']?\s*([^\s"'/>;]+)/i) ?? prefix.match(/]+content\s*=\s*["'][^"']*charset=([^\s"';>]+)/i); return match?.[1]?.toLowerCase() ?? declared; } function decodeBody(raw: Uint8Array, charset: string): { text: string; charset: string } { try { return { text: new TextDecoder(charset).decode(raw), charset }; } catch { return { text: new TextDecoder("utf-8").decode(raw), charset: "utf-8" }; } } function looksTextual(raw: Uint8Array): boolean { const sample = raw.subarray(0, Math.min(raw.byteLength, 4_096)); if (sample.includes(0)) return false; let controls = 0; for (const byte of sample) { if (byte < 9 || (byte > 13 && byte < 32)) controls += 1; } return sample.length === 0 || controls / sample.length < 0.02; } function magicImageExtension(raw: Uint8Array): string | undefined { const bytes = Buffer.from(raw.subarray(0, 16)); if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "jpg"; if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "png"; if (bytes.subarray(0, 6).toString("ascii") === "GIF87a" || bytes.subarray(0, 6).toString("ascii") === "GIF89a") return "gif"; if (bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP") return "webp"; if (bytes.subarray(0, 2).toString("ascii") === "BM") return "bmp"; if (bytes.subarray(0, 4).toString("hex") === "00000100") return "ico"; return undefined; } function isGenericMediaType(mediaType: string): boolean { return !mediaType || mediaType === "application/octet-stream" || mediaType === "application/binary"; } function detectDocumentFormat(raw: Uint8Array, mediaType: string, extension: string | undefined): SupportedFileType | undefined { const mimeFormat = DOCUMENT_MIME_TYPES.get(mediaType); if (mimeFormat) return mimeFormat; const prefix = Buffer.from(raw.subarray(0, 8)).toString("ascii"); if (prefix.startsWith("%PDF-")) return "pdf"; if (prefix.startsWith("{\\rtf")) return "rtf"; if ((isGenericMediaType(mediaType) || mediaType === "application/zip") && extension && DOCUMENT_FORMATS.has(extension as SupportedFileType)) { return extension as SupportedFileType; } return undefined; } function detectTextFormat(raw: Uint8Array, mediaType: string, extension: string | undefined): string | undefined { const exact = TEXT_MIME_FORMATS.get(mediaType); if (exact) return exact; if (mediaType.endsWith("+json")) return "json"; if (mediaType.endsWith("+xml")) return "xml"; if (mediaType.startsWith("text/")) return extension === "htm" ? "html" : extension ?? "txt"; if (isGenericMediaType(mediaType)) { if (extension === "html" || extension === "htm" || extension === "xhtml") return "html"; if (extension && TEXT_EXTENSIONS.has(extension)) return extension === "markdown" ? "md" : extension; if (!looksTextual(raw)) return undefined; const prefix = new TextDecoder("utf-8").decode(raw.subarray(0, Math.min(raw.byteLength, 512))).trimStart().toLowerCase(); if (/^]|^]|^]/.test(prefix)) return "html"; if (prefix.startsWith("{") || prefix.startsWith("[")) return "json"; if (prefix.startsWith("]/.test(prefix)) return "xml"; return extension ?? "txt"; } return undefined; } function fencedMarkdown(title: string, source: string, language: string): string { const fence = source.includes("````") ? "`````" : source.includes("```") ? "````" : "```"; return `# ${title}\n\n${fence}${language}\n${source.trim()}\n${fence}`; } function conversionText(value: unknown): string { if (typeof value === "string") return value.trim(); if (value instanceof Uint8Array) return Buffer.from(value).toString("utf8").trim(); return ""; } async function parseDocument( raw: Uint8Array, format: SupportedFileType, options: ParseFetchContentOptions, extension: string, ): Promise { const fallbackTitle = titleFromUrl(options.finalUrl); try { const ast = await OfficeParser.parseOffice(raw, { fileType: format, ocr: false, extractAttachments: false, includeRawContent: false, abortSignal: options.signal, decompressionLimits: { maxUncompressedBytes: 64 * 1024 * 1024, maxZipEntries: 2_000, }, }); const [textResult, markdownResult] = await Promise.all([ ast.to("text"), ast.to("md"), ]); const text = conversionText(textResult.value); const generatedMarkdown = conversionText(markdownResult.value); const metadataTitle = typeof ast.metadata.title === "string" ? ast.metadata.title.trim() : ""; const title = metadataTitle || fallbackTitle; const markdown = generatedMarkdown || (text ? markdownFromPlainText(text, options.finalUrl).markdown : undefined); const sourceText = format === "rtf" ? decodeBody(raw, options.declaredCharset).text : undefined; return { kind: "document", sourceFormat: format, extension, charset: sourceText === undefined ? "binary" : options.declaredCharset, title, ...(sourceText === undefined ? {} : { sourceText }), ...(text ? { text } : {}), ...(markdown ? { markdown } : {}), }; } catch (error) { if (options.signal?.aborted) throw options.signal.reason ?? error; return { kind: "document", sourceFormat: format, extension, charset: "binary", title: fallbackTitle, parseWarning: `Could not extract ${format.toUpperCase()} text: ${error instanceof Error ? error.message : String(error)}`, }; } } export async function parseFetchedContent( raw: Uint8Array, options: ParseFetchContentOptions, ): Promise { const mediaType = options.mediaType.toLowerCase(); const urlExtension = sourceExtension(options); const documentFormat = detectDocumentFormat(raw, mediaType, urlExtension); if (documentFormat) { return parseDocument(raw, documentFormat, options, documentFormat); } const imageExtension = IMAGE_MIME_EXTENSIONS.get(mediaType) ?? (isGenericMediaType(mediaType) && urlExtension && IMAGE_EXTENSIONS.has(urlExtension) ? urlExtension : undefined) ?? magicImageExtension(raw); if (mediaType.startsWith("image/") || imageExtension) { return { kind: "image", sourceFormat: mediaType || `image/${imageExtension ?? "unknown"}`, extension: safeExtension(imageExtension, "img"), charset: "binary", title: titleFromUrl(options.finalUrl), }; } const textFormat = detectTextFormat(raw, mediaType, urlExtension); if (textFormat) { const charset = sniffCharset(raw, options.declaredCharset); const decoded = decodeBody(raw, charset); if (textFormat === "html") { const extracted = extractMarkdownFromHtml(decoded.text, options.finalUrl); return { kind: "html", sourceFormat: "html", extension: urlExtension && ["html", "htm", "xhtml"].includes(urlExtension) ? urlExtension : "html", charset: decoded.charset, title: extracted.title, sourceText: decoded.text, text: extracted.markdown, markdown: extracted.markdown, }; } const title = titleFromUrl(options.finalUrl); const markdown = textFormat === "md" || textFormat === "markdown" ? decoded.text.trim() : textFormat === "txt" ? markdownFromPlainText(decoded.text, options.finalUrl).markdown : fencedMarkdown(title, decoded.text, textFormat); return { kind: "text", sourceFormat: textFormat, extension: urlExtension && TEXT_EXTENSIONS.has(urlExtension) ? urlExtension : textFormat === "markdown" ? "md" : textFormat, charset: decoded.charset, title, sourceText: decoded.text, text: decoded.text, markdown, }; } return { kind: "binary", sourceFormat: mediaType || "application/octet-stream", extension: safeExtension(urlExtension, "bin"), charset: "binary", title: titleFromUrl(options.finalUrl), }; }