diff --git a/src/app/app.component.ts b/src/app/app.component.ts index cd086ee..c8de3b2 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -9,6 +9,8 @@ import { UploadService } from '@app/services/upload.service'; import { PreviewComponent } from '@app/components/preview/preview.component'; import { HeaderComponent } from '@app/components/header/header.component'; import { DropAreaDirective } from '@app/directives/drop-area.directive'; +import { MatIconRegistry } from '@angular/material/icon'; +import { DomSanitizer } from '@angular/platform-browser'; @Component({ selector: 'app-root', @@ -38,4 +40,26 @@ export class AppComponent { async onHovering(value: boolean) { this.isHovering = value; } + + constructor() { + // const host = isPlatformServer(inject(PLATFORM_ID)) ? environment.webHost : '.'; + const iconRegistry = inject(MatIconRegistry); + const sanitizer = inject(DomSanitizer); + iconRegistry.addSvgIcon( + 'zip', + sanitizer.bypassSecurityTrustResourceUrl(`./assets/filetype/zip.svg`) + ); + iconRegistry.addSvgIcon( + 'xml', + sanitizer.bypassSecurityTrustResourceUrl(`./assets/filetype/xml.svg`) + ); + iconRegistry.addSvgIcon( + 'image', + sanitizer.bypassSecurityTrustResourceUrl(`./assets/filetype/image.svg`) + ); + iconRegistry.addSvgIcon( + 'unknown', + sanitizer.bypassSecurityTrustResourceUrl(`./assets/filetype/unknown.svg`) + ); + } } diff --git a/src/app/components/attachment-dialog/attachment-dialog.component.html b/src/app/components/attachment-dialog/attachment-dialog.component.html new file mode 100644 index 0000000..776fdab --- /dev/null +++ b/src/app/components/attachment-dialog/attachment-dialog.component.html @@ -0,0 +1,18 @@ +

+ {{ data.name }} +

+ + +

Description: {{ data.description }}

+

MimeType: {{ data.mimeType }}

+
+ + +
{{ fileContent }}
+
+ + + + diff --git a/src/app/components/attachment-dialog/attachment-dialog.component.scss b/src/app/components/attachment-dialog/attachment-dialog.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/components/attachment-dialog/attachment-dialog.component.ts b/src/app/components/attachment-dialog/attachment-dialog.component.ts new file mode 100644 index 0000000..8775365 --- /dev/null +++ b/src/app/components/attachment-dialog/attachment-dialog.component.ts @@ -0,0 +1,36 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslocoPipe } from '@jsverse/transloco'; + +import { DocumentAttachment } from '@app/types/attachment'; + +@Component({ + selector: 'app-attachment-dialog', + templateUrl: './attachment-dialog.component.html', + styleUrls: ['./attachment-dialog.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + DatePipe, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatIconModule, + TranslocoPipe, + ], +}) +export class AttachmentDialogComponent { + readonly data = inject(MAT_DIALOG_DATA); + + fileContent: string | undefined; + + constructor() { + this.fileContent = new TextDecoder().decode(this.data.data); + } +} diff --git a/src/app/components/attachments/attachments.component.html b/src/app/components/attachments/attachments.component.html new file mode 100644 index 0000000..bd71aba --- /dev/null +++ b/src/app/components/attachments/attachments.component.html @@ -0,0 +1,27 @@ + + + Attachments: {{ attachments().length }} + + + + @for (attachment of attachments(); track attachment.name) { +
+ + + @if (attachment.mimeType.includes('image/')) { + + } @else if (attachment.mimeType.includes('xml')) { + + } @else { + + } + +
+ {{ attachment.name }} +
+
+ } @empty {} +
+
diff --git a/src/app/components/attachments/attachments.component.scss b/src/app/components/attachments/attachments.component.scss new file mode 100644 index 0000000..a77258f --- /dev/null +++ b/src/app/components/attachments/attachments.component.scss @@ -0,0 +1,45 @@ +:host { + display: block; + width: 100%; + padding-left: 1rem; + padding-bottom: 1.25rem; + padding-right: 6rem; +} + +.item { + position: relative; + border-radius: 0.25rem; + border: grey solid 2px; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + box-sizing: border-box; + + margin: 0.5rem; + max-width: 200px; + + &:hover { + border-color: #3f51b5; + } + + .tool { + position: absolute; + top: 0; + right: 0; + } +} + +.icon.large { + font-size: 3rem; + width: 3rem; + height: 3rem; +} + +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} diff --git a/src/app/components/attachments/attachments.component.ts b/src/app/components/attachments/attachments.component.ts new file mode 100644 index 0000000..fb3925d --- /dev/null +++ b/src/app/components/attachments/attachments.component.ts @@ -0,0 +1,42 @@ +import { + Component, + ChangeDetectionStrategy, + inject, + input, +} from '@angular/core'; +import { + MatExpansionPanel, + MatExpansionPanelContent, + MatExpansionPanelHeader, +} from '@angular/material/expansion'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { TranslocoPipe } from '@jsverse/transloco'; + +import { DocumentAttachment } from '@app/types/attachment'; +import { LazyDialogService } from '@app/services/lazy-dialog.service'; + +@Component({ + selector: 'app-attachments', + templateUrl: './attachments.component.html', + styleUrls: ['./attachments.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + MatExpansionPanel, + MatExpansionPanelHeader, + MatExpansionPanelContent, + MatIconModule, + MatButtonModule, + TranslocoPipe, + ], +}) +export class AttachmentsComponent { + private readonly lazyDialogService = inject(LazyDialogService); + + readonly attachments = input.required(); + + inspectAttachment(attachment: DocumentAttachment) { + this.lazyDialogService.openAttachmentDialog(attachment); + } +} diff --git a/src/app/components/pages/pages.component.html b/src/app/components/pages/pages.component.html index 22db459..21351d4 100644 --- a/src/app/components/pages/pages.component.html +++ b/src/app/components/pages/pages.component.html @@ -5,17 +5,18 @@ (cdkDropListDropped)="onChangePosition($event)" > @for (page of pages(); track page) { -
- +
+ - -
+ +
} @empty {} + +
+ @defer (when hasAttachments()) { + + } +
diff --git a/src/app/components/pages/pages.component.scss b/src/app/components/pages/pages.component.scss index 88a9cc1..8552041 100644 --- a/src/app/components/pages/pages.component.scss +++ b/src/app/components/pages/pages.component.scss @@ -1,11 +1,26 @@ :host { - display: block; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 1rem; position: relative; text-align: center; + + height: 100%; } .list { display: flex; + flex: 0 1 auto; + flex-wrap: wrap; + flex-direction: row; + box-sizing: border-box; + overflow: auto; +} + +.attachments { + display: flex; + flex: 0 1 auto; flex-wrap: wrap; flex-direction: row; box-sizing: border-box; @@ -17,6 +32,7 @@ border: grey solid 2px; display: flex; + flex-direction: column; align-items: center; justify-content: center; box-sizing: border-box; @@ -27,12 +43,12 @@ &:hover { border-color: #3f51b5; } -} -button.remove { - position: absolute; - top: 0; - right: 0; + .tool { + position: absolute; + top: 0; + right: 0; + } } .cdk-drag-preview { diff --git a/src/app/components/pages/pages.component.ts b/src/app/components/pages/pages.component.ts index 44b3324..b7f0450 100644 --- a/src/app/components/pages/pages.component.ts +++ b/src/app/components/pages/pages.component.ts @@ -12,6 +12,7 @@ import { TranslocoPipe } from '@jsverse/transloco'; import { DocumentService } from '@app/services/document.service'; import { EmptyComponent } from '../empty/empty.component'; import { ThumbnailComponent } from '../thumb/thumb.component'; +import { AttachmentsComponent } from '../attachments/attachments.component'; @Component({ selector: 'app-pages', @@ -25,13 +26,17 @@ import { ThumbnailComponent } from '../thumb/thumb.component'; DragDropModule, TranslocoPipe, - ThumbnailComponent, EmptyComponent, + ThumbnailComponent, + AttachmentsComponent, ], }) export class PagesComponent { private readonly documentService = inject(DocumentService); + readonly attachments = this.documentService.attachments; + readonly hasAttachments = computed(() => this.attachments().length > 0); + readonly pageCount = this.documentService.pageCount; readonly pages = computed(() => { const pageCount = this.pageCount(); diff --git a/src/app/components/preview/preview.component.scss b/src/app/components/preview/preview.component.scss index 9e2fced..dd4015b 100644 --- a/src/app/components/preview/preview.component.scss +++ b/src/app/components/preview/preview.component.scss @@ -1,4 +1,6 @@ :host { + display: block; + height: 100%; position: relative; text-align: center; } diff --git a/src/app/helpers/file.helper.ts b/src/app/helpers/file.helper.ts index 6148d52..6e4bbb8 100644 --- a/src/app/helpers/file.helper.ts +++ b/src/app/helpers/file.helper.ts @@ -7,6 +7,7 @@ export const readFileAsDataURLAsync = (file: File) => }; reader.onerror = reject; + reader.onabort = reject; reader.readAsDataURL(file); }); diff --git a/src/app/helpers/pdf.helper.ts b/src/app/helpers/pdf.helper.ts new file mode 100644 index 0000000..531e40b --- /dev/null +++ b/src/app/helpers/pdf.helper.ts @@ -0,0 +1,58 @@ +import { + decodePDFRawStream, + PDFArray, + PDFDict, + PDFDocument, + PDFHexString, + PDFName, + PDFRawStream, + PDFStream, + PDFString, +} from 'pdf-lib'; + +export const extractRawAttachments = (pdfDoc: PDFDocument) => { + if (!pdfDoc.catalog.has(PDFName.of('Names'))) return []; + const Names = pdfDoc.catalog.lookup(PDFName.of('Names'), PDFDict); + + if (!Names.has(PDFName.of('EmbeddedFiles'))) return []; + let EmbeddedFiles = Names.lookup(PDFName.of('EmbeddedFiles'), PDFDict); + + if ( + !EmbeddedFiles.has(PDFName.of('Names')) && + EmbeddedFiles.has(PDFName.of('Kids')) + ) + EmbeddedFiles = EmbeddedFiles.lookup(PDFName.of('Kids'), PDFArray).lookup( + 0 + ) as PDFDict; + + if (!EmbeddedFiles.has(PDFName.of('Names'))) return []; + const EFNames = EmbeddedFiles.lookup(PDFName.of('Names'), PDFArray); + + const rawAttachments = []; + for (let idx = 0, len = EFNames.size(); idx < len; idx += 2) { + const fileName = EFNames.lookup(idx) as PDFHexString | PDFString; + const fileSpec = EFNames.lookup(idx + 1, PDFDict); + rawAttachments.push({ fileName, fileSpec }); + } + + return rawAttachments; +}; + +export const extractAttachments = (pdfDoc: PDFDocument) => { + const rawAttachments = extractRawAttachments(pdfDoc); + return rawAttachments.map(({ fileName, fileSpec }) => { + const stream = fileSpec + .lookup(PDFName.of('EF'), PDFDict) + .lookup(PDFName.of('F'), PDFStream) as PDFRawStream; + + const description = fileSpec.lookup(PDFName.of('Desc'), PDFString); + const subtype = stream.dict.lookup(PDFName.of('Subtype'), PDFName); + + return { + name: fileName.decodeText(), + description: description.decodeText(), + mimeType: subtype.decodeText(), + data: decodePDFRawStream(stream).decode(), + }; + }); +}; diff --git a/src/app/services/document.service.ts b/src/app/services/document.service.ts index f218576..47bc075 100644 --- a/src/app/services/document.service.ts +++ b/src/app/services/document.service.ts @@ -2,6 +2,7 @@ import { Injectable, computed, inject } from '@angular/core'; import { PDFDocument } from 'pdf-lib'; import { DocumentMetadata } from '@app/types/metadata'; +import { extractAttachments } from '@app/helpers/pdf.helper'; import { StoreService } from './store.service'; @Injectable({ providedIn: 'root' }) @@ -16,6 +17,12 @@ export class DocumentService { return this.document?.getPageCount() || 0; }); + readonly attachments = computed(() => { + if (!this.storeService.documentBuffer()) return []; + + return this.getAttachments(); + }); + async loadPDF(pdfBuffer: ArrayBuffer) { this.document = await PDFDocument.load(pdfBuffer); await this.storeService.updateDocumentBuffer(this.document); @@ -100,6 +107,13 @@ export class DocumentService { this.document?.setKeywords((metadata.keywords || '').split(' ')); } + getAttachments() { + if (this.document) { + return extractAttachments(this.document); + } + return []; + } + async save(fileName: string) { if (!this.document) return; diff --git a/src/app/services/lazy-dialog.service.ts b/src/app/services/lazy-dialog.service.ts index 50c0017..e85b895 100644 --- a/src/app/services/lazy-dialog.service.ts +++ b/src/app/services/lazy-dialog.service.ts @@ -3,6 +3,7 @@ import { MatDialog } from '@angular/material/dialog'; import { firstValueFrom } from 'rxjs'; import { DocumentMetadata } from '@app/types/metadata'; +import { DocumentAttachment } from '@app/types/attachment'; @Injectable({ providedIn: 'root' }) export class LazyDialogService { @@ -24,6 +25,17 @@ export class LazyDialogService { return this.dialog.open(component.PropertiesDialogComponent, { data }); } + async openAttachmentDialog(data: DocumentAttachment) { + const component = await import( + '../components/attachment-dialog/attachment-dialog.component' + ); + return this.dialog.open(component.AttachmentDialogComponent, { + data, + maxWidth: '92vw', + maxHeight: '92vh', + }); + } + async openPropertiesDialogAsync(data: DocumentMetadata) { const ref = await this.openPropertiesDialog(data); const result = await firstValueFrom(ref.afterClosed()); diff --git a/src/app/types/attachment.ts b/src/app/types/attachment.ts new file mode 100644 index 0000000..a810549 --- /dev/null +++ b/src/app/types/attachment.ts @@ -0,0 +1,6 @@ +export interface DocumentAttachment { + name: string; + description: string; + mimeType: string; + data: Uint8Array; +} diff --git a/src/assets/filetype/image.svg b/src/assets/filetype/image.svg new file mode 100644 index 0000000..843e3e0 --- /dev/null +++ b/src/assets/filetype/image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/filetype/unknown.svg b/src/assets/filetype/unknown.svg new file mode 100644 index 0000000..3f54175 --- /dev/null +++ b/src/assets/filetype/unknown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/filetype/xml.svg b/src/assets/filetype/xml.svg new file mode 100644 index 0000000..2a68180 --- /dev/null +++ b/src/assets/filetype/xml.svg @@ -0,0 +1 @@ + \ No newline at end of file