Files
pdf/frontend/src/lib/gatewayService.ts
T

730 lines
22 KiB
TypeScript

export interface PageInfo {
index: number;
width: number;
height: number;
}
export class PasswordError extends Error {
detail: string;
constructor(detail: string) {
super(detail);
this.name = 'PasswordError';
this.detail = detail;
}
}
export interface PDFPermissions {
isEncrypted: boolean;
encryption: string;
securityRevision: number;
ownerUnlocked: boolean;
canPrint: boolean;
canPrintHighRes: boolean;
canModify: boolean;
canCopy: boolean;
canAnnotate: boolean;
canFillForms: boolean;
canExtractForAccessibility: boolean;
canAssemble: boolean;
}
export interface DocumentInfo {
id: string;
filename: string;
sizeBytes: number;
totalPages: number;
pageWidth: number;
pageHeight: number;
uploadedAt: string;
status: 'processing' | 'ready' | 'error';
pages?: PageInfo[];
permissions?: PDFPermissions;
}
export interface RenderParams {
documentId: string;
pageIndex: number;
zoom: number;
rotation: number;
}
export interface Annotation {
id: string;
pageIndex: number;
type: string;
x: number;
y: number;
width: number;
height: number;
fieldName?: string;
fieldValue?: string;
fieldType?: string;
fieldFlags?: number;
fieldOptions?: string[];
}
import type { Point } from './coordinateMapping';
export interface SearchRect {
x: number;
y: number;
w: number;
h: number;
}
export interface SearchResult {
pageIndex: number;
rects: SearchRect[];
text: string;
}
export interface Glyph {
text: string;
x: number;
y: number;
w: number;
h: number;
fontSize?: number;
}
export interface PageText {
text: string;
glyphs: Glyph[];
}
export interface DocumentMetadata {
title?: string;
author?: string;
subject?: string;
keywords?: string;
creator?: string;
producer?: string;
creation_date?: string;
modification_date?: string;
}
export interface FontInfo {
fontName: string;
type?: string;
isEmbedded?: boolean;
isSubset?: boolean;
isVertical?: boolean;
encoding?: string;
hasToUnicode?: boolean;
cmapName?: string;
cidSystemInfo?: string;
subsetTag?: string;
sourceType?: string;
substitutedFrom?: string;
substitutedTo?: string;
normalizedFamily?: string;
internalFontId?: string;
flags?: number;
ascent?: number;
descent?: number;
capHeight?: number;
}
export interface TextOverlayData {
text: string;
x: number;
y: number;
width: number;
height: number;
fontSize: number;
fontFamily: string;
color: string;
}
export interface RedactionData {
x: number;
y: number;
width: number;
height: number;
fillColor: string;
}
export interface ImageOverlayData {
x: number;
y: number;
width: number;
height: number;
imageData: string;
}
export interface HighlightQuadPoint {
x1: number;
y1: number;
x2: number;
y2: number;
x3: number;
y3: number;
x4: number;
y4: number;
}
export interface HighlightData {
quadPoints: HighlightQuadPoint[];
color: string;
opacity: number;
author: string;
content?: string;
}
export interface FreeTextData {
x: number;
y: number;
width: number;
height: number;
text: string;
fontSize: number;
color: string;
}
export interface StickyNoteData {
x: number;
y: number;
author: string;
content: string;
timestamp?: string;
}
export interface FreehandData {
paths: Point[][];
color: string;
thickness: number;
}
export interface PageRotationData {
rotation: 0 | 90 | 180 | 270;
}
export interface PageDeletionData { }
export interface PageReorderData {
destPageIndex: number;
}
export type EditOperationDataMap = {
text_overlay: TextOverlayData;
redaction: RedactionData;
image_overlay: ImageOverlayData;
highlight: HighlightData;
free_text: FreeTextData;
comment: StickyNoteData;
freehand: FreehandData;
page_rotation: PageRotationData;
page_deletion: PageDeletionData;
page_reorder: PageReorderData;
delete_annotation: DeleteAnnotationData;
update_annotation: UpdateAnnotationData;
replace_text: ReplaceTextData;
reflow_paragraph: ReflowParagraphData;
underline: DecorationData;
strikeout: DecorationData;
squiggly: DecorationData;
};
export interface DecorationData {
quadPoints: HighlightQuadPoint[];
color: string;
author?: string;
content?: string;
}
export interface ReplaceTextData {
objectIndices: number[];
text: string;
internalFontId: string;
fontSize: number;
disableJustify?: boolean;
}
export interface ReflowFragment {
text: string;
internalFontId: string;
fontSize: number;
color: string;
advances?: number[];
}
export interface ReflowParagraphData {
objectIndices: number[];
runs: ReflowFragment[];
columnLeft: number;
columnRight: number;
firstBaselineY: number;
leading: number;
oldLineCount: number;
align: 'left' | 'justify' | 'center' | 'right';
pushColumnLeft?: number;
paraId?: string;
lines?: ReflowFragment[][];
lineBaselineY?: number[];
lineX?: number[];
}
export interface DeleteAnnotationData {
annotationId: string;
}
export interface UpdateAnnotationData {
annotationId: string;
x?: number;
y?: number;
width?: number;
height?: number;
color?: string;
thickness?: number;
text?: string;
}
export interface OutlineItem {
title: string;
pageIndex: number;
level: number;
}
export type EditOperationType = keyof EditOperationDataMap;
export interface EditOperationBase<T extends EditOperationType> {
id: string;
type: T;
pageIndex: number;
data: EditOperationDataMap[T];
}
export type EditOperation = {
[T in EditOperationType]: EditOperationBase<T>;
}[EditOperationType];
export interface EditOperationEnvelope {
version: '1.0';
operations: EditOperation[];
}
export interface TextObjectResponse {
text: string;
fontName: string;
fontSize: number;
tm: number[];
}
class GatewayService {
public baseUrl: string;
constructor() {
this.baseUrl = import.meta.env.VITE_GATEWAY_URL || 'http://127.0.0.1:8000';
}
async getHealth(): Promise<{ status: string; version: string; engine_available: boolean }> {
const response = await fetch(`${this.baseUrl}/health`);
if (!response.ok) throw new Error(`Health check failed: ${response.statusText}`);
return response.json();
}
async listDocuments(): Promise<DocumentInfo[]> {
try {
const response = await fetch(`${this.baseUrl}/documents`);
if (response.status === 501) {
return this.getMockDocuments();
}
if (!response.ok) throw new Error(`Failed to list documents: ${response.statusText}`);
return response.json();
} catch (err) {
return this.getMockDocuments();
}
}
async uploadDocument(file: File, password = ''): Promise<DocumentInfo> {
const formData = new FormData();
formData.append('file', file);
const url = password
? `${this.baseUrl}/documents?password=${encodeURIComponent(password)}`
: `${this.baseUrl}/documents`;
const response = await fetch(url, {
method: 'POST',
body: formData,
});
if (response.status === 401) {
const body = await response.json().catch(() => ({ detail: 'Password required' }));
throw new PasswordError(body.detail || 'Password required');
}
if (response.status === 501) {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
id: `doc_${Math.random().toString(36).substr(2, 9)}`,
filename: file.name,
sizeBytes: file.size,
totalPages: 5,
pageWidth: 612,
pageHeight: 792,
uploadedAt: new Date().toISOString(),
status: 'ready',
pages: Array.from({ length: 5 }, (_, i) => ({ index: i, width: 612, height: 792 })),
});
}, 1000);
});
}
if (!response.ok) throw new Error(`Upload failed: ${response.statusText}`);
return response.json();
}
async getDocument(id: string): Promise<DocumentInfo> {
try {
const response = await fetch(`${this.baseUrl}/documents/${id}`);
if (response.status === 501) {
const mock = this.getMockDocuments().find(d => d.id === id);
if (!mock) throw new Error('Document not found');
return mock;
}
if (!response.ok) throw new Error(`Failed to fetch document metadata: ${response.statusText}`);
return response.json();
} catch (err) {
const mock = this.getMockDocuments().find(d => d.id === id);
if (!mock) throw new Error('Document not found');
return mock;
}
}
async deleteDocument(id: string): Promise<{ success: boolean }> {
const response = await fetch(`${this.baseUrl}/documents/${id}`, {
method: 'DELETE',
});
if (response.status === 501) {
return { success: true };
}
if (!response.ok) throw new Error(`Failed to delete document: ${response.statusText}`);
return response.json();
}
async getDocumentAnnotations(id: string): Promise<any[]> {
try {
const response = await fetch(`${this.baseUrl}/documents/${id}/annotations`);
if (response.status === 501) {
return [];
}
if (!response.ok) throw new Error(`Failed to fetch document annotations: ${response.statusText}`);
return response.json();
} catch (err) {
return [];
}
}
async renderPage(params: RenderParams): Promise<string> {
try {
const query = new URLSearchParams({
page: params.pageIndex.toString(),
zoom: params.zoom.toString(),
rotation: params.rotation.toString(),
}).toString();
const url = `${this.baseUrl}/render/${params.documentId}?${query}`;
const response = await fetch(url);
if (response.status === 501) {
return this.generateMockPage(params.pageIndex);
}
if (!response.ok) throw new Error(`Page render failed: ${response.statusText}`);
const blob = await response.blob();
return URL.createObjectURL(blob);
} catch (err) {
return this.generateMockPage(params.pageIndex);
}
}
async getPageModel(documentId: string, pageIndex: number): Promise<any> {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/pages/${pageIndex}/model`);
if (!response.ok) throw new Error(`Failed to get page model: ${response.statusText}`);
return response.json();
}
async getDocumentRaw(documentId: string): Promise<ArrayBuffer | null> {
try {
const r = await fetch(`${this.baseUrl}/documents/${documentId}/raw`);
if (!r.ok) return null;
return await r.arrayBuffer();
} catch {
return null;
}
}
async getFontData(documentId: string, internalFontId: string): Promise<ArrayBuffer | null> {
try {
const url = `${this.baseUrl}/documents/${documentId}/font?internal_font_id=${encodeURIComponent(internalFontId)}`;
const response = await fetch(url);
if (!response.ok) return null;
return await response.arrayBuffer();
} catch {
return null;
}
}
async getReconstructedFontData(documentId: string, internalFontId: string): Promise<ArrayBuffer | null> {
try {
const url = `${this.baseUrl}/documents/${documentId}/font-reconstructed?internal_font_id=${encodeURIComponent(internalFontId)}`;
const response = await fetch(url);
if (!response.ok || response.status === 204) return null;
const buf = await response.arrayBuffer();
return buf.byteLength ? buf : null;
} catch {
return null;
}
}
async applyEdits(documentId: string, operations: EditOperation[]): Promise<{ success: boolean; newDocumentId: string }> {
let response: Response;
try {
response = await fetch(`${this.baseUrl}/documents/${documentId}/edits`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version: '1.0', operations } as EditOperationEnvelope),
});
} catch {
return { success: true, newDocumentId: `${documentId}_edited` };
}
if (response.status === 501) {
return { success: true, newDocumentId: `${documentId}_edited` };
}
if (!response.ok) {
let detail = response.statusText;
try {
const j = await response.json();
if (j?.detail) detail = typeof j.detail === 'string' ? j.detail : JSON.stringify(j.detail);
} catch { }
throw new Error(detail);
}
return response.json();
}
async searchDocument(documentId: string, query: string, caseSensitive: boolean = false, wholeWords: boolean = false): Promise<SearchResult[]> {
if (!query) return [];
const urlParams = new URLSearchParams({
q: query,
...(caseSensitive && { case_sensitive: 'true' }),
...(wholeWords && { whole_words: 'true' })
});
const response = await fetch(`${this.baseUrl}/documents/${documentId}/search?${urlParams.toString()}`);
if (response.status === 501) {
return [];
}
if (!response.ok) throw new Error(`Failed to search document: ${response.statusText}`);
return response.json();
}
async getTextObjects(documentId: string, pageIndex: number): Promise<TextObjectResponse[]> {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/pages/${pageIndex}/text_objects`);
if (!response.ok) throw new Error(`Failed to get text objects: ${response.statusText}`);
return response.json();
}
async updateTextObject(documentId: string, pageIndex: number, objectIndex: number, newText: string): Promise<{ success: boolean; newDocumentId?: string }> {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/pages/${pageIndex}/text_objects/${objectIndex}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_text: newText }),
});
if (!response.ok) {
let detail = response.statusText;
try { const j = await response.json(); if (j?.detail) detail = j.detail; } catch { }
throw new Error(detail);
}
return response.json();
}
async getPageDisplayList(documentId: string, pageIndex: number): Promise<any[]> {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/pages/${pageIndex}/display_list`);
if (!response.ok) throw new Error(`Failed to get display list: ${response.statusText}`);
return response.json();
}
getImageXObjectUrl(documentId: string, pageIndex: number, name: string): string {
return `${this.baseUrl}/documents/${documentId}/pages/${pageIndex}/xobjects/${encodeURIComponent(name)}`;
}
private getMockDocuments(): DocumentInfo[] {
return [
{
id: 'sample-doc-1',
filename: 'Quarterly_Financial_Report.pdf',
sizeBytes: 1024 * 1024 * 3.4,
totalPages: 12,
pageWidth: 612,
pageHeight: 792,
uploadedAt: new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString(),
status: 'ready',
pages: Array.from({ length: 12 }, (_, i) => ({ index: i, width: 612, height: 792 })),
},
{
id: 'sample-doc-2',
filename: 'Engineering_Specification_v4.pdf',
sizeBytes: 1024 * 1024 * 18.2,
totalPages: 54,
pageWidth: 612,
pageHeight: 792,
uploadedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(),
status: 'ready',
pages: Array.from({ length: 54 }, (_, i) => ({ index: i, width: 612, height: 792 })),
},
{
id: 'sample-doc-3',
filename: 'Tenant_Lease_Agreement_Final.pdf',
sizeBytes: 1024 * 245,
totalPages: 4,
pageWidth: 612,
pageHeight: 792,
uploadedAt: new Date(Date.now() - 1000 * 60 * 45).toISOString(),
status: 'ready',
pages: Array.from({ length: 4 }, (_, i) => ({ index: i, width: 612, height: 792 })),
}
];
}
private generateMockPage(pageIndex: number): string {
const width = 800;
const height = 1100;
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
<rect width="${width}" height="${height}" fill="#fafbfc" />
<!-- Shadow border effect -->
<rect x="5" y="5" width="${width - 10}" height="${height - 10}" fill="white" rx="8" filter="drop-shadow(0 4px 6px rgba(0,0,0,0.05))" />
<!-- Header -->
<text x="60" y="80" font-family="system-ui, sans-serif" font-size="28" font-weight="800" fill="#1e293b">PDF Editor</text>
<text x="60" y="110" font-family="system-ui, sans-serif" font-size="12" font-weight="500" fill="#64748b" letter-spacing="1">PAGE ${pageIndex + 1} OF THE SPECIFICATION</text>
<line x1="60" y1="130" x2="${width - 60}" y2="130" stroke="#f1f5f9" stroke-width="2" />
<!-- Document Content Simulation -->
<rect x="60" y="160" width="120" height="24" fill="#eff6ff" rx="4" />
<text x="70" y="176" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#3b82f6">SECTION ${pageIndex * 2 + 1}.1</text>
<text x="60" y="210" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#0f172a">High-Performance Rendering Architecture</text>
<!-- Multiline mock text -->
<rect x="60" y="235" width="${width - 120}" height="10" fill="#e2e8f0" rx="3" />
<rect x="60" y="255" width="${width - 160}" height="10" fill="#e2e8f0" rx="3" />
<rect x="60" y="275" width="${width - 120}" height="10" fill="#e2e8f0" rx="3" />
<rect x="60" y="295" width="${width - 240}" height="10" fill="#e2e8f0" rx="3" />
<!-- Draw some abstract blueprints / charts on even pages to make them look distinct -->
${pageIndex % 2 === 0 ? `
<!-- Mock Chart -->
<rect x="60" y="340" width="${width - 120}" height="280" fill="#f8fafc" rx="12" stroke="#e2e8f0" stroke-width="1.5" />
<path d="M 100 580 Q 200 420 300 480 T 500 380 T 700 420" fill="none" stroke="url(#chartGrad)" stroke-width="4" stroke-linecap="round" />
<circle cx="300" cy="480" r="6" fill="#3b82f6" stroke="white" stroke-width="2" />
<circle cx="500" cy="380" r="6" fill="#10b981" stroke="white" stroke-width="2" />
<text x="80" y="375" font-family="system-ui, sans-serif" font-size="14" font-weight="700" fill="#334155">Engine Performance Vectors (Mock Data)</text>
<defs>
<linearGradient id="chartGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3b82f6" />
<stop offset="100%" stop-color="#10b981" />
</linearGradient>
</defs>
` : `
<!-- Structured List -->
<circle cx="70" cy="360" r="4" fill="#3b82f6" />
<rect x="90" y="355" width="${width - 160}" height="10" fill="#cbd5e1" rx="3" />
<circle cx="70" cy="390" r="4" fill="#3b82f6" />
<rect x="90" y="385" width="${width - 200}" height="10" fill="#cbd5e1" rx="3" />
<circle cx="70" cy="420" r="4" fill="#3b82f6" />
<rect x="90" y="415" width="${width - 140}" height="10" fill="#cbd5e1" rx="3" />
<!-- A block of code/preformatted text -->
<rect x="60" y="470" width="${width - 120}" height="180" fill="#0f172a" rx="12" />
<text x="90" y="510" font-family="monospace" font-size="13" fill="#38bdf8">#include &lt;pdfengine/core.hpp&gt;</text>
<text x="90" y="535" font-family="monospace" font-size="13" fill="#f87171">int main() {</text>
<text x="120" y="560" font-family="monospace" font-size="13" fill="#a7f3d0"> pdf::Engine engine;</text>
<text x="120" y="585" font-family="monospace" font-size="13" fill="#a7f3d0"> engine.load_document("quarterly.pdf");</text>
<text x="120" y="610" font-family="monospace" font-size="13" fill="#fbbf24"> return engine.render_page(1, 2.0);</text>
<text x="90" y="635" font-family="monospace" font-size="13" fill="#f87171">}</text>
`}
<!-- Footer -->
<line x1="60" y1="1020" x2="${width - 60}" y2="1020" stroke="#f1f5f9" stroke-width="1.5" />
<text x="60" y="1045" font-family="system-ui, sans-serif" font-size="11" font-weight="600" fill="#94a3b8">PDF Editor — preview render</text>
<text x="${width - 100}" y="1045" font-family="system-ui, sans-serif" font-size="11" font-weight="700" fill="#94a3b8">PAGE ${pageIndex + 1}</text>
</svg>
`;
return `data:image/svg+xml;utf8,${encodeURIComponent(svg.trim())}`;
}
async getPageText(documentId: string, pageIndex: number): Promise<PageText> {
try {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/pages/${pageIndex}/text`);
if (!response.ok) return { text: '', glyphs: [] };
const data = await response.json();
return { text: data.text || '', glyphs: Array.isArray(data.glyphs) ? data.glyphs : [] };
} catch {
return { text: '', glyphs: [] };
}
}
async getDocumentMetadata(documentId: string): Promise<DocumentMetadata> {
try {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/metadata`);
if (!response.ok) return {};
return response.json();
} catch {
return {};
}
}
async getDocumentFonts(documentId: string): Promise<FontInfo[]> {
try {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/fonts`);
if (!response.ok) return [];
const data = await response.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
async getOutline(documentId: string): Promise<OutlineItem[]> {
try {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/outline`);
if (!response.ok) return [];
const data = await response.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
async exportDocument(documentId: string, filename: string): Promise<void> {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/export`);
if (!response.ok) throw new Error(`Export failed: ${response.statusText}`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
async fetchDocumentBytes(documentId: string): Promise<ArrayBuffer> {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/export`);
if (!response.ok) throw new Error(`Failed to fetch document bytes: ${response.statusText}`);
return response.arrayBuffer();
}
}
export const gatewayService = new GatewayService();