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 {
|
export interface PdfByteStore {
|
||||||
put(documentId: DocumentId, bytes: Uint8Array): PdfByteRecord;
|
put(
|
||||||
|
documentId: DocumentId,
|
||||||
|
bytes: Uint8Array,
|
||||||
|
contentType?: string,
|
||||||
|
): PdfByteRecord;
|
||||||
get(documentId: DocumentId): PdfByteRecord | null;
|
get(documentId: DocumentId): PdfByteRecord | null;
|
||||||
has(documentId: DocumentId): boolean;
|
has(documentId: DocumentId): boolean;
|
||||||
delete(documentId: DocumentId): boolean;
|
delete(documentId: DocumentId): boolean;
|
||||||
|
|
@ -70,7 +74,7 @@ export function createPdfByteStore(
|
||||||
const records = new Map<DocumentId, PdfByteRecord>();
|
const records = new Map<DocumentId, PdfByteRecord>();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
put(documentId, bytes) {
|
put(documentId, bytes, contentType = "application/pdf") {
|
||||||
// Replace previous record (revoking the prior URL) if any.
|
// Replace previous record (revoking the prior URL) if any.
|
||||||
const prior = records.get(documentId);
|
const prior = records.get(documentId);
|
||||||
if (prior) revokeUrl(prior.blobUrl);
|
if (prior) revokeUrl(prior.blobUrl);
|
||||||
|
|
@ -79,7 +83,7 @@ export function createPdfByteStore(
|
||||||
// refuses without help. The bytes here always come from a fresh
|
// refuses without help. The bytes here always come from a fresh
|
||||||
// arrayBuffer() call, so a regular ArrayBuffer is guaranteed.
|
// arrayBuffer() call, so a regular ArrayBuffer is guaranteed.
|
||||||
const blob = new Blob([bytes as unknown as ArrayBuffer], {
|
const blob = new Blob([bytes as unknown as ArrayBuffer], {
|
||||||
type: "application/pdf",
|
type: contentType,
|
||||||
});
|
});
|
||||||
const blobUrl = createUrl(blob);
|
const blobUrl = createUrl(blob);
|
||||||
const record: PdfByteRecord = { bytes, blobUrl };
|
const record: PdfByteRecord = { bytes, blobUrl };
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,12 @@ export {
|
||||||
type PdfByteStore,
|
type PdfByteStore,
|
||||||
} from "./byte-store";
|
} from "./byte-store";
|
||||||
export {
|
export {
|
||||||
|
ingestHtmlFromFile,
|
||||||
|
ingestMarkdownFromFile,
|
||||||
ingestPdfFromFile,
|
ingestPdfFromFile,
|
||||||
type IngestPdfFromFileOptions,
|
type IngestPdfFromFileOptions,
|
||||||
} from "./upload";
|
} from "./upload";
|
||||||
|
export {
|
||||||
|
markdownSourceToViewerHtml,
|
||||||
|
resolveDomViewerHtml,
|
||||||
|
} from "./view-html";
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@
|
||||||
* place that revokes them.
|
* 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 { ingestPdf, type IngestPdfResult } from "../pdf/ingest";
|
||||||
import type { PdfByteStore } from "./byte-store";
|
import type { PdfByteStore } from "./byte-store";
|
||||||
|
|
||||||
|
|
@ -37,7 +39,39 @@ export async function ingestPdfFromFile(
|
||||||
...(filename !== undefined ? { filename } : {}),
|
...(filename !== undefined ? { filename } : {}),
|
||||||
...(options.title !== undefined ? { title: options.title } : {}),
|
...(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 };
|
const document = { ...ingested.document, uri: record.blobUrl };
|
||||||
return { document, representation: ingested.representation };
|
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