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