Add browser upload and viewer HTML resolution for HTML/Markdown
ingestHtmlFromFile and ingestMarkdownFromFile store session bytes with correct content types. resolveDomViewerHtml renders sanitized HTML or markdown paragraphs for HtmlViewerAdapter (EANCH-WP-0004).
This commit is contained in:
parent
4999ea31f2
commit
d605bf64be
5 changed files with 112 additions and 4 deletions
|
|
@ -25,7 +25,11 @@ export interface PdfByteRecord {
|
|||
}
|
||||
|
||||
export interface PdfByteStore {
|
||||
put(documentId: DocumentId, bytes: Uint8Array): PdfByteRecord;
|
||||
put(
|
||||
documentId: DocumentId,
|
||||
bytes: Uint8Array,
|
||||
contentType?: string,
|
||||
): PdfByteRecord;
|
||||
get(documentId: DocumentId): PdfByteRecord | null;
|
||||
has(documentId: DocumentId): boolean;
|
||||
delete(documentId: DocumentId): boolean;
|
||||
|
|
@ -70,7 +74,7 @@ export function createPdfByteStore(
|
|||
const records = new Map<DocumentId, PdfByteRecord>();
|
||||
|
||||
return {
|
||||
put(documentId, bytes) {
|
||||
put(documentId, bytes, contentType = "application/pdf") {
|
||||
// Replace previous record (revoking the prior URL) if any.
|
||||
const prior = records.get(documentId);
|
||||
if (prior) revokeUrl(prior.blobUrl);
|
||||
|
|
@ -79,7 +83,7 @@ export function createPdfByteStore(
|
|||
// refuses without help. The bytes here always come from a fresh
|
||||
// arrayBuffer() call, so a regular ArrayBuffer is guaranteed.
|
||||
const blob = new Blob([bytes as unknown as ArrayBuffer], {
|
||||
type: "application/pdf",
|
||||
type: contentType,
|
||||
});
|
||||
const blobUrl = createUrl(blob);
|
||||
const record: PdfByteRecord = { bytes, blobUrl };
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ export {
|
|||
type PdfByteStore,
|
||||
} from "./byte-store";
|
||||
export {
|
||||
ingestHtmlFromFile,
|
||||
ingestMarkdownFromFile,
|
||||
ingestPdfFromFile,
|
||||
type IngestPdfFromFileOptions,
|
||||
} from "./upload";
|
||||
export {
|
||||
markdownSourceToViewerHtml,
|
||||
resolveDomViewerHtml,
|
||||
} from "./view-html";
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@
|
|||
* place that revokes them.
|
||||
*/
|
||||
|
||||
import { ingestHtml, type IngestHtmlResult } from "../html/ingest";
|
||||
import { ingestMarkdown, type IngestMarkdownResult } from "../markdown/ingest";
|
||||
import { ingestPdf, type IngestPdfResult } from "../pdf/ingest";
|
||||
import type { PdfByteStore } from "./byte-store";
|
||||
|
||||
|
|
@ -37,7 +39,39 @@ export async function ingestPdfFromFile(
|
|||
...(filename !== undefined ? { filename } : {}),
|
||||
...(options.title !== undefined ? { title: options.title } : {}),
|
||||
});
|
||||
const record = store.put(ingested.document.id, bytes);
|
||||
const record = store.put(ingested.document.id, bytes, "application/pdf");
|
||||
const document = { ...ingested.document, uri: record.blobUrl };
|
||||
return { document, representation: ingested.representation };
|
||||
}
|
||||
|
||||
export async function ingestHtmlFromFile(
|
||||
file: File | Blob,
|
||||
store: PdfByteStore,
|
||||
options: IngestPdfFromFileOptions = {},
|
||||
): Promise<IngestHtmlResult> {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const filename = "name" in file && typeof file.name === "string" ? file.name : undefined;
|
||||
const ingested = await ingestHtml(bytes, {
|
||||
...(filename !== undefined ? { filename } : {}),
|
||||
...(options.title !== undefined ? { title: options.title } : {}),
|
||||
});
|
||||
const record = store.put(ingested.document.id, bytes, "text/html");
|
||||
const document = { ...ingested.document, uri: record.blobUrl };
|
||||
return { document, representation: ingested.representation };
|
||||
}
|
||||
|
||||
export async function ingestMarkdownFromFile(
|
||||
file: File | Blob,
|
||||
store: PdfByteStore,
|
||||
options: IngestPdfFromFileOptions = {},
|
||||
): Promise<IngestMarkdownResult> {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const filename = "name" in file && typeof file.name === "string" ? file.name : undefined;
|
||||
const ingested = await ingestMarkdown(bytes, {
|
||||
...(filename !== undefined ? { filename } : {}),
|
||||
...(options.title !== undefined ? { title: options.title } : {}),
|
||||
});
|
||||
const record = store.put(ingested.document.id, bytes, "text/markdown");
|
||||
const document = { ...ingested.document, uri: record.blobUrl };
|
||||
return { document, representation: ingested.representation };
|
||||
}
|
||||
|
|
|
|||
23
src/browser/view-html.test.ts
Normal file
23
src/browser/view-html.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createPdfByteStore } from "./byte-store";
|
||||
import { markdownSourceToViewerHtml, resolveDomViewerHtml } from "./view-html";
|
||||
import { ingestHtmlFromFile } from "./upload";
|
||||
|
||||
const HTML = `<!DOCTYPE html><html><body><p>Hello HTML viewer</p></body></html>`;
|
||||
|
||||
describe("resolveDomViewerHtml", () => {
|
||||
it("returns sanitized HTML for html-dom representations", async () => {
|
||||
const store = createPdfByteStore();
|
||||
const file = new File([HTML], "sample.html", { type: "text/html" });
|
||||
const { document, representation } = await ingestHtmlFromFile(file, store);
|
||||
const html = resolveDomViewerHtml(document, representation, store);
|
||||
expect(html).toContain("Hello HTML viewer");
|
||||
expect(html).not.toMatch(/<script/i);
|
||||
});
|
||||
|
||||
it("renders markdown as paragraph HTML", () => {
|
||||
const html = markdownSourceToViewerHtml("# Title\n\nBody paragraph.");
|
||||
expect(html).toContain("<p>");
|
||||
expect(html).toContain("Body paragraph.");
|
||||
});
|
||||
});
|
||||
41
src/browser/view-html.ts
Normal file
41
src/browser/view-html.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { Document, DocumentRepresentation } from "@citation-evidence/engine/shared";
|
||||
import { extractHtml } from "../html/extract";
|
||||
import { extractMarkdown } from "../markdown/extract";
|
||||
import type { PdfByteStore } from "./byte-store";
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** Render markdown canonical text as selectable HTML paragraphs. */
|
||||
export function markdownSourceToViewerHtml(source: string): string {
|
||||
const { canonicalText } = extractMarkdown(source);
|
||||
const paragraphs = canonicalText.split(/\n\n+/).map((p) => p.trim()).filter(Boolean);
|
||||
const body = paragraphs.map((p) => `<p>${escapeHtml(p)}</p>`).join("");
|
||||
return `<article class="ea-markdown-body">${body}</article>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve viewer HTML for a non-paginated document from session byte storage.
|
||||
* Returns `null` when bytes are missing or the representation is unsupported.
|
||||
*/
|
||||
export function resolveDomViewerHtml(
|
||||
document: Document,
|
||||
representation: DocumentRepresentation,
|
||||
store: PdfByteStore,
|
||||
): string | null {
|
||||
const record = store.get(document.id);
|
||||
if (!record) return null;
|
||||
const source = new TextDecoder("utf-8", { fatal: false }).decode(record.bytes);
|
||||
if (representation.representationType === "html-dom") {
|
||||
return extractHtml(source).sanitizedHtml;
|
||||
}
|
||||
if (representation.representationType === "markdown-rendered") {
|
||||
return markdownSourceToViewerHtml(source);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue