Merge branch 'main' into add-lang-tr

This commit is contained in:
Alam
2026-01-08 13:58:26 +05:30
committed by GitHub
378 changed files with 149653 additions and 22218 deletions

817
src/css/markdown-editor.css Normal file
View File

@@ -0,0 +1,817 @@
.md-editor-wrapper {
display: flex;
flex-direction: column;
gap: 1rem;
}
.md-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.md-editor-title {
font-size: 1.125rem;
font-weight: 600;
color: white;
}
.md-editor-actions {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.md-editor-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.md-editor-btn svg {
width: 16px;
height: 16px;
}
.md-editor-btn-secondary {
color: rgb(156, 163, 175);
background: rgb(31, 41, 55);
border: 1px solid rgb(55, 65, 81);
}
.md-editor-btn-secondary:hover {
background: rgb(55, 65, 81);
color: white;
}
.md-editor-btn-primary {
color: white;
background: rgb(79, 70, 229);
}
.md-editor-btn-primary:hover {
background: rgb(99, 102, 241);
}
.md-editor-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Theme Toggle */
.theme-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
background: rgb(31, 41, 55);
border: 1px solid rgb(55, 65, 81);
border-radius: 0.375rem;
font-size: 0.875rem;
color: rgb(156, 163, 175);
}
.theme-toggle-slider {
position: relative;
width: 40px;
height: 20px;
background: rgb(55, 65, 81);
border-radius: 10px;
cursor: pointer;
transition: background 0.2s;
}
.theme-toggle-slider::before {
content: '';
position: absolute;
width: 16px;
height: 16px;
left: 2px;
top: 2px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
}
.theme-toggle-slider.active {
background: rgb(79, 70, 229);
}
.theme-toggle-slider.active::before {
transform: translateX(20px);
}
/* Split View Container */
.md-editor-main {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
height: 600px;
border: 1px solid rgb(55, 65, 81);
border-radius: 0.5rem;
overflow: hidden;
}
.md-editor-pane {
display: flex;
flex-direction: column;
min-height: 0;
background: rgb(31, 41, 55);
}
.md-editor-pane-header {
padding: 0.75rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: rgb(156, 163, 175);
background: rgb(17, 24, 39);
border-bottom: 1px solid rgb(55, 65, 81);
flex-shrink: 0;
}
/* Separator between panes */
.md-editor-pane:first-child {
border-right: 2px solid rgb(55, 65, 81);
}
.md-editor.light-mode .md-editor-pane:first-child {
border-right-color: rgb(229, 231, 235);
}
/* Editor Textarea */
.md-editor-textarea {
flex: 1;
width: 100%;
padding: 1rem;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.875rem;
line-height: 1.6;
color: rgb(229, 231, 235);
background: rgb(31, 41, 55);
border: none;
outline: none;
resize: none;
overflow-y: auto;
}
.md-editor-textarea::placeholder {
color: rgb(107, 114, 128);
}
.md-editor.light-mode .md-editor-pane {
background: white;
}
.md-editor.light-mode .md-editor-pane-header {
background: rgb(249, 250, 251);
color: rgb(75, 85, 99);
border-bottom-color: rgb(229, 231, 235);
}
.md-editor.light-mode .md-editor-textarea {
background: white;
color: rgb(17, 24, 39);
}
.md-editor.light-mode .md-editor-preview {
background: white;
color: rgb(17, 24, 39);
}
.md-editor-preview {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
background: rgb(31, 41, 55);
color: rgb(229, 231, 235);
}
.md-editor-preview h1,
.md-editor-preview h2,
.md-editor-preview h3,
.md-editor-preview h4,
.md-editor-preview h5,
.md-editor-preview h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.25;
color: white;
}
.md-editor-preview h1:first-child,
.md-editor-preview h2:first-child,
.md-editor-preview h3:first-child {
margin-top: 0;
}
.md-editor-preview h1 {
font-size: 2em;
border-bottom: 1px solid rgb(55, 65, 81);
padding-bottom: 0.3em;
}
.md-editor-preview h2 {
font-size: 1.5em;
border-bottom: 1px solid rgb(55, 65, 81);
padding-bottom: 0.3em;
}
.md-editor-preview h3 {
font-size: 1.25em;
}
.md-editor-preview p {
margin: 1em 0;
line-height: 1.6;
}
.md-editor-preview a {
color: rgb(96, 165, 250);
text-decoration: none;
}
.md-editor-preview a:hover {
text-decoration: underline;
}
.md-editor-preview code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.9em;
background: rgb(17, 24, 39);
color: rgb(251, 191, 36);
padding: 0.2em 0.4em;
border-radius: 3px;
}
.md-editor-preview pre {
background: rgb(17, 24, 39);
padding: 1rem;
overflow: auto;
border-radius: 0.5rem;
line-height: 1.45;
margin: 1em 0;
border: 1px solid rgb(55, 65, 81);
}
.md-editor-preview pre code {
background: none;
color: rgb(229, 231, 235);
padding: 0;
}
.md-editor-preview blockquote {
margin: 1em 0;
padding: 0 1em;
color: rgb(156, 163, 175);
border-left: 4px solid rgb(79, 70, 229);
}
.md-editor-preview ul,
.md-editor-preview ol {
margin: 1em 0;
padding-left: 2em;
}
.md-editor-preview li {
margin: 0.25em 0;
}
.md-editor-preview table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
.md-editor-preview th,
.md-editor-preview td {
border: 1px solid rgb(55, 65, 81);
padding: 8px 12px;
text-align: left;
}
.md-editor-preview th {
background: rgb(17, 24, 39);
font-weight: 600;
color: white;
}
.md-editor-preview tr:nth-child(even) {
background: rgb(17, 24, 39);
}
.md-editor-preview hr {
border: none;
border-top: 1px solid rgb(55, 65, 81);
margin: 2em 0;
}
.md-editor-preview img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
}
/* Light Mode Preview Styles */
.md-editor.light-mode .md-editor-preview h1,
.md-editor.light-mode .md-editor-preview h2,
.md-editor.light-mode .md-editor-preview h3,
.md-editor.light-mode .md-editor-preview h4,
.md-editor.light-mode .md-editor-preview h5,
.md-editor.light-mode .md-editor-preview h6 {
color: rgb(17, 24, 39);
}
.md-editor.light-mode .md-editor-preview h1,
.md-editor.light-mode .md-editor-preview h2 {
border-bottom-color: rgb(229, 231, 235);
}
.md-editor.light-mode .md-editor-preview a {
color: rgb(29, 78, 216);
}
.md-editor.light-mode .md-editor-preview code {
background: rgb(249, 250, 251);
color: rgb(220, 38, 38);
}
.md-editor.light-mode .md-editor-preview pre {
background: rgb(249, 250, 251);
border-color: rgb(229, 231, 235);
}
.md-editor.light-mode .md-editor-preview pre code {
color: rgb(17, 24, 39);
}
.md-editor.light-mode .md-editor-preview blockquote {
color: rgb(75, 85, 99);
border-left-color: rgb(99, 102, 241);
}
.md-editor.light-mode .md-editor-preview th,
.md-editor.light-mode .md-editor-preview td {
border-color: rgb(229, 231, 235);
}
.md-editor.light-mode .md-editor-preview th {
background: rgb(249, 250, 251);
color: rgb(17, 24, 39);
}
.md-editor.light-mode .md-editor-preview tr:nth-child(even) {
background: rgb(249, 250, 251);
}
.md-editor.light-mode .md-editor-preview hr {
border-top-color: rgb(229, 231, 235);
}
/* Markdown Plugin Styles */
.md-editor-preview mark {
background-color: rgb(254, 243, 199);
color: rgb(17, 24, 39);
padding: 0.1em 0.2em;
border-radius: 2px;
}
.md-editor-preview ins {
text-decoration: none;
background-color: rgb(134, 239, 172);
color: rgb(17, 24, 39);
padding: 0.1em 0.2em;
border-radius: 2px;
}
.md-editor-preview .task-list-item {
list-style-type: none;
margin-left: -1.5em;
}
.md-editor-preview .footnotes {
margin-top: 2em;
padding-top: 1em;
border-top: 1px solid rgb(55, 65, 81);
font-size: 0.9em;
}
.md-editor.light-mode .md-editor-preview .footnotes {
border-top-color: rgb(229, 231, 235);
}
.md-editor-preview .table-of-contents {
background: rgb(17, 24, 39);
padding: 1em 1.5em;
border-radius: 0.5rem;
margin: 1em 0;
border: 1px solid rgb(55, 65, 81);
}
.md-editor.light-mode .md-editor-preview .table-of-contents {
background: rgb(249, 250, 251);
border-color: rgb(229, 231, 235);
}
/* Mermaid Diagrams */
.md-editor-preview .mermaid-diagram {
display: flex;
justify-content: center;
margin: 1.5em 0;
padding: 1em;
background: rgb(17, 24, 39);
border-radius: 0.5rem;
border: 1px solid rgb(55, 65, 81);
}
.md-editor-preview .mermaid-diagram svg {
max-width: 100%;
height: auto;
}
.md-editor.light-mode .md-editor-preview .mermaid-diagram {
background: rgb(249, 250, 251);
border-color: rgb(229, 231, 235);
}
.md-editor-preview .mermaid-error {
color: rgb(248, 113, 113);
background: rgba(239, 68, 68, 0.1);
padding: 1em;
border-radius: 0.5rem;
font-family: monospace;
font-size: 0.9em;
border: 1px solid rgb(239, 68, 68);
}
.md-editor.light-mode .md-editor-preview .mermaid-error {
color: rgb(185, 28, 28);
background: rgb(254, 242, 242);
border-color: rgb(252, 165, 165);
}
/* Settings Modal */
.md-editor-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
z-index: 9998;
display: flex;
align-items: center;
justify-content: center;
}
.md-editor-modal {
background: rgb(31, 41, 55);
border-radius: 0.75rem;
padding: 1.5rem;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
border: 1px solid rgb(55, 65, 81);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
}
.md-editor-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.md-editor-modal-title {
font-size: 1.25rem;
font-weight: 600;
color: white;
}
.md-editor-modal-close {
padding: 0.5rem;
color: rgb(156, 163, 175);
background: transparent;
border: none;
cursor: pointer;
border-radius: 0.375rem;
transition: all 0.2s;
}
.md-editor-modal-close:hover {
background: rgb(55, 65, 81);
color: white;
}
.md-editor-settings-group {
margin-bottom: 1.5rem;
}
.md-editor-settings-group h3 {
font-size: 0.875rem;
font-weight: 600;
color: white;
margin-bottom: 0.75rem;
}
.md-editor-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0;
font-size: 0.875rem;
color: rgb(156, 163, 175);
cursor: pointer;
}
.md-editor-checkbox:hover {
color: white;
}
.md-editor-settings-group select {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
color: white;
background: rgb(17, 24, 39);
border: 1px solid rgb(55, 65, 81);
border-radius: 0.375rem;
cursor: pointer;
width: 100%;
}
.md-editor-settings-group select:hover {
border-color: rgb(75, 85, 99);
}
.md-editor-settings-group select:focus {
outline: none;
border-color: rgb(79, 70, 229);
}
/* Print Styles */
@media print {
/* Reset all backgrounds, margins, and padding */
html,
body {
background: white !important;
background-color: white !important;
margin: 0 !important;
padding: 0 !important;
}
/* Hide all elements by default */
body * {
visibility: hidden;
}
/* Hide UI elements completely - don't take up space */
.md-editor-header,
.md-editor-pane-header,
.md-editor-textarea,
.md-editor-modal-overlay,
.md-editor-pane:first-child,
header,
nav,
footer,
#back-to-tools,
.bg-gray-800>h1.text-2xl,
.bg-gray-800>p.text-gray-400,
#markdown-editor-container~h1,
#markdown-editor-container~p {
display: none !important;
}
.min-h-screen,
.max-w-7xl,
.bg-gray-800,
.rounded-xl,
.shadow-xl,
.p-6,
.py-8,
.px-4,
#markdown-editor-container,
[class*="container"] {
padding: 0 !important;
margin: 0 !important;
background: white !important;
background-color: white !important;
border: none !important;
box-shadow: none !important;
border-radius: 0 !important;
}
/* Reset container backgrounds and layouts */
.md-editor,
.md-editor-wrapper,
.md-editor-main,
.md-editor-pane {
background: white !important;
background-color: white !important;
border: none !important;
height: auto !important;
overflow: visible !important;
display: block !important;
visibility: visible !important;
margin: 0 !important;
padding: 0 !important;
gap: 0 !important;
}
.md-editor-main {
grid-template-columns: 1fr !important;
}
/* Make preview and contents visible */
.md-editor-preview,
.md-editor-preview * {
visibility: visible !important;
}
.md-editor-preview {
position: static !important;
width: 100% !important;
height: auto !important;
max-height: none !important;
overflow: visible !important;
background: white !important;
color: black !important;
padding: 0 !important;
}
.md-editor-preview h1,
.md-editor-preview h2,
.md-editor-preview h3,
.md-editor-preview h4,
.md-editor-preview h5,
.md-editor-preview h6 {
color: black !important;
page-break-after: avoid;
}
.md-editor-preview>h1:first-child,
.md-editor-preview>*:first-child {
margin-top: 0 !important;
}
.md-editor-preview p,
.md-editor-preview li,
.md-editor-preview span {
color: black !important;
}
.md-editor-preview a {
color: #0366d6 !important;
}
.md-editor-preview code {
background: #f6f8fa !important;
color: #24292e !important;
}
.md-editor-preview pre {
background: #f6f8fa !important;
border: 1px solid #e1e4e8 !important;
page-break-inside: avoid;
}
.md-editor-preview pre code {
color: #24292e !important;
}
.md-editor-preview blockquote {
color: #6a737d !important;
border-left-color: #dfe2e5 !important;
}
.md-editor-preview table {
page-break-inside: avoid;
}
.md-editor-preview th,
.md-editor-preview td {
border-color: #e1e4e8 !important;
}
.md-editor-preview th {
background: #f6f8fa !important;
color: black !important;
}
.md-editor-preview tr:nth-child(even) {
background: #f6f8fa !important;
}
.md-editor-preview .mermaid-diagram {
background: #f6f8fa !important;
border: 1px solid #e1e4e8 !important;
page-break-inside: avoid;
}
.md-editor-preview .mermaid-diagram svg {
max-width: 100% !important;
}
}
/* Responsive */
@media (max-width: 768px) {
.md-editor-header {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
.md-editor-actions {
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.md-editor-btn {
flex: 1 1 auto;
min-width: fit-content;
}
.md-editor-btn span {
display: none;
}
.theme-toggle {
padding: 0.5rem;
}
.md-editor-main {
grid-template-columns: 1fr;
height: auto;
gap: 0;
}
.md-editor-pane {
min-height: 300px;
max-height: 400px;
}
.md-editor-textarea,
.md-editor-preview {
overflow-x: auto;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.md-editor-pane:first-child {
border-right: none;
border-bottom: 2px solid rgb(55, 65, 81);
}
.md-editor.light-mode .md-editor-pane:first-child {
border-right: none;
border-bottom-color: rgb(229, 231, 235);
}
.md-editor-modal {
width: 95%;
max-height: 90vh;
}
.md-editor-settings-group {
margin-bottom: 1rem;
}
}
@media (max-width: 1024px) and (min-width: 769px) {
.md-editor-main {
height: 500px;
}
.md-editor-btn {
padding: 0.5rem 0.75rem;
}
}
@media (max-width: 480px) {
.md-editor-pane {
min-height: 250px;
max-height: 350px;
}
.md-editor-btn {
padding: 0.5rem;
}
.md-editor-header {
gap: 0.5rem;
}
.md-editor-actions {
gap: 0.375rem;
}
}

View File

@@ -59,6 +59,13 @@ body {
transform 0.2s ease-in-out,
box-shadow 0.2s ease-in-out;
border: 1px solid #374151;
background-color: #1f2937;
padding: 1.5rem;
border-radius: 0.5rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.tool-card:hover {

View File

@@ -6,68 +6,68 @@ export const categories = [
{
href: import.meta.env.BASE_URL + 'pdf-multi-tool.html',
name: 'PDF Multi Tool',
icon: 'pencil-ruler',
icon: 'ph-pencil-ruler',
subtitle: 'Merge, Split, Organize, Delete, Rotate, Add Blank Pages, Extract and Duplicate in an unified interface.',
},
{
href: import.meta.env.BASE_URL + 'merge-pdf.html',
name: 'Merge PDF',
icon: 'combine',
icon: 'ph-browsers',
subtitle: 'Combine multiple PDFs into one file. Preserves Bookmarks.',
},
{
href: import.meta.env.BASE_URL + 'split-pdf.html',
name: 'Split PDF',
icon: 'scissors',
icon: 'ph-scissors',
subtitle: 'Extract a range of pages into a new PDF.',
},
{
href: import.meta.env.BASE_URL + 'compress-pdf.html',
name: 'Compress PDF',
icon: 'zap',
icon: 'ph-lightning',
subtitle: 'Reduce the file size of your PDF.',
},
{
href: import.meta.env.BASE_URL + 'edit-pdf.html',
name: 'PDF Editor',
icon: 'pocket-knife',
icon: 'ph-pencil-simple',
subtitle:
'Annotate, highlight, redact, comment, add shapes/images, search, and view PDFs',
},
{
href: import.meta.env.BASE_URL + 'jpg-to-pdf.html',
name: 'JPG to PDF',
icon: 'image-up',
icon: 'ph-file-jpg',
subtitle: 'Create a PDF from one or more JPG images.',
},
{
href: import.meta.env.BASE_URL + 'sign-pdf.html',
name: 'Sign PDF',
icon: 'pen-tool',
icon: 'ph-pen-nib',
subtitle: 'Draw, type, or upload your signature.',
},
{
href: import.meta.env.BASE_URL + 'crop-pdf.html',
name: 'Crop PDF',
icon: 'crop',
icon: 'ph-crop',
subtitle: 'Trim the margins of every page in your PDF.',
},
{
href: import.meta.env.BASE_URL + 'extract-pages.html',
name: 'Extract Pages',
icon: 'ungroup',
icon: 'ph-squares-four',
subtitle: 'Save a selection of pages as new files.',
},
{
href: import.meta.env.BASE_URL + 'organize-pdf.html',
name: 'Duplicate & Organize',
icon: 'files',
icon: 'ph-files',
subtitle: 'Duplicate, reorder, and delete pages.',
},
{
href: import.meta.env.BASE_URL + 'delete-pages.html',
name: 'Delete Pages',
icon: 'trash-2',
icon: 'ph-trash',
subtitle: 'Remove specific pages from your document.',
},
],
@@ -78,98 +78,98 @@ export const categories = [
{
href: import.meta.env.BASE_URL + 'edit-pdf.html',
name: 'PDF Editor',
icon: 'pocket-knife',
icon: 'ph-pencil-simple',
subtitle:
'Annotate, highlight, redact, comment, add shapes/images, search, and view PDFs.',
},
{
href: import.meta.env.BASE_URL + 'bookmark.html',
name: 'Edit Bookmarks',
icon: 'bookmark',
icon: 'ph-bookmark',
subtitle: 'Add, edit, import, delete and extract PDF bookmarks.',
},
{
href: import.meta.env.BASE_URL + 'table-of-contents.html',
name: 'Table of Contents',
icon: 'list',
icon: 'ph-list',
subtitle: 'Generate a table of contents page from PDF bookmarks.',
},
{
href: import.meta.env.BASE_URL + 'page-numbers.html',
name: 'Page Numbers',
icon: 'list-ordered',
icon: 'ph-list-numbers',
subtitle: 'Insert page numbers into your document.',
},
{
href: import.meta.env.BASE_URL + 'add-watermark.html',
name: 'Add Watermark',
icon: 'droplets',
icon: 'ph-drop',
subtitle: 'Stamp text or an image over your PDF pages.',
},
{
href: import.meta.env.BASE_URL + 'header-footer.html',
name: 'Header & Footer',
icon: 'pilcrow',
icon: 'ph-paragraph',
subtitle: 'Add text to the top and bottom of pages.',
},
{
href: import.meta.env.BASE_URL + 'invert-colors.html',
name: 'Invert Colors',
icon: 'contrast',
icon: 'ph-circle-half',
subtitle: 'Create a "dark mode" version of your PDF.',
},
{
href: import.meta.env.BASE_URL + 'background-color.html',
name: 'Background Color',
icon: 'palette',
icon: 'ph-palette',
subtitle: 'Change the background color of your PDF.',
},
{
href: import.meta.env.BASE_URL + 'text-color.html',
name: 'Change Text Color',
icon: 'type',
icon: 'ph-eyedropper',
subtitle: 'Change the color of text in your PDF.',
},
{
href: import.meta.env.BASE_URL + 'sign-pdf.html',
name: 'Sign PDF',
icon: 'pen-tool',
icon: 'ph-pen-nib',
subtitle: 'Draw, type, or upload your signature.',
},
{
href: import.meta.env.BASE_URL + 'add-stamps.html',
name: 'Add Stamps',
icon: 'stamp',
icon: 'ph-stamp',
subtitle: 'Add image stamps to your PDF using the annotation toolbar.',
},
{
href: import.meta.env.BASE_URL + 'remove-annotations.html',
name: 'Remove Annotations',
icon: 'eraser',
icon: 'ph-eraser',
subtitle: 'Strip comments, highlights, and links.',
},
{
href: import.meta.env.BASE_URL + 'crop-pdf.html',
name: 'Crop PDF',
icon: 'crop',
icon: 'ph-crop',
subtitle: 'Trim the margins of every page in your PDF.',
},
{
href: import.meta.env.BASE_URL + 'form-filler.html',
name: 'PDF Form Filler',
icon: 'square-pen',
icon: 'ph-pencil-line',
subtitle: 'Fill in forms directly in the browser. Also supports XFA forms.',
},
{
href: import.meta.env.BASE_URL + 'form-creator.html',
name: 'Create PDF Form',
icon: 'file-input',
icon: 'ph-file-plus',
subtitle: 'Create fillable PDF forms with drag-and-drop text fields.',
},
{
href: import.meta.env.BASE_URL + 'remove-blank-pages.html',
name: 'Remove Blank Pages',
icon: 'file-minus-2',
icon: 'ph-file-minus',
subtitle: 'Automatically detect and delete blank pages.',
},
],
@@ -179,64 +179,196 @@ export const categories = [
tools: [
{
href: import.meta.env.BASE_URL + 'image-to-pdf.html',
name: 'Image to PDF',
icon: 'images',
subtitle: 'Convert JPG, PNG, WebP, BMP, TIFF, SVG, HEIC to PDF.',
name: 'Images to PDF',
icon: 'ph-images',
subtitle: 'Convert JPG, PNG, BMP, GIF, TIFF, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2, PSD, SVG, HEIC, WebP to PDF.',
},
{
href: import.meta.env.BASE_URL + 'jpg-to-pdf.html',
name: 'JPG to PDF',
icon: 'image-up',
icon: 'ph-file-jpg',
subtitle: 'Create a PDF from one or more JPG images.',
},
{
href: import.meta.env.BASE_URL + 'png-to-pdf.html',
name: 'PNG to PDF',
icon: 'image-up',
icon: 'ph-file-png',
subtitle: 'Create a PDF from one or more PNG images.',
},
{
href: import.meta.env.BASE_URL + 'webp-to-pdf.html',
name: 'WebP to PDF',
icon: 'image-up',
icon: 'ph-image',
subtitle: 'Create a PDF from one or more WebP images.',
},
{
href: import.meta.env.BASE_URL + 'svg-to-pdf.html',
name: 'SVG to PDF',
icon: 'pen-tool',
icon: 'ph-file-svg',
subtitle: 'Create a PDF from one or more SVG images.',
},
{
href: import.meta.env.BASE_URL + 'bmp-to-pdf.html',
name: 'BMP to PDF',
icon: 'image',
icon: 'ph-image',
subtitle: 'Create a PDF from one or more BMP images.',
},
{
href: import.meta.env.BASE_URL + 'heic-to-pdf.html',
name: 'HEIC to PDF',
icon: 'smartphone',
icon: 'ph-device-mobile',
subtitle: 'Create a PDF from one or more HEIC images.',
},
{
href: import.meta.env.BASE_URL + 'tiff-to-pdf.html',
name: 'TIFF to PDF',
icon: 'layers',
icon: 'ph-image',
subtitle: 'Create a PDF from one or more TIFF images.',
},
{
href: import.meta.env.BASE_URL + 'txt-to-pdf.html',
name: 'Text to PDF',
icon: 'file-pen',
icon: 'ph-text-t',
subtitle: 'Convert a plain text file into a PDF.',
},
{
href: import.meta.env.BASE_URL + 'markdown-to-pdf.html',
name: 'Markdown to PDF',
icon: 'ph-markdown-logo',
subtitle: 'Convert Markdown to PDF with live preview and syntax highlighting.',
},
{
href: import.meta.env.BASE_URL + 'json-to-pdf.html',
name: 'JSON to PDF',
icon: 'file-code',
icon: 'ph-file-code',
subtitle: 'Convert JSON files to PDF format.',
},
{
href: import.meta.env.BASE_URL + 'odt-to-pdf.html',
name: 'ODT to PDF',
icon: 'ph-file',
subtitle: 'Convert ODT (OpenDocument Text) files to PDF.',
},
{
href: import.meta.env.BASE_URL + 'csv-to-pdf.html',
name: 'CSV to PDF',
icon: 'ph-file-csv',
subtitle: 'Convert CSV (Comma-Separated Values) spreadsheets to PDF.',
},
{
href: import.meta.env.BASE_URL + 'rtf-to-pdf.html',
name: 'RTF to PDF',
icon: 'ph-file-text',
subtitle: 'Convert RTF (Rich Text Format) documents to PDF.',
},
{
href: import.meta.env.BASE_URL + 'word-to-pdf.html',
name: 'Word to PDF',
icon: 'ph-microsoft-word-logo',
subtitle: 'Convert Word documents (DOCX, DOC, ODT) to PDF.',
},
{
href: import.meta.env.BASE_URL + 'excel-to-pdf.html',
name: 'Excel to PDF',
icon: 'ph-microsoft-excel-logo',
subtitle: 'Convert Excel spreadsheets (XLSX, XLS, ODS) to PDF.',
},
{
href: import.meta.env.BASE_URL + 'powerpoint-to-pdf.html',
name: 'PowerPoint to PDF',
icon: 'ph-microsoft-powerpoint-logo',
subtitle: 'Convert PowerPoint presentations (PPTX, PPT, ODP) to PDF.',
},
{
href: import.meta.env.BASE_URL + 'xps-to-pdf.html',
name: 'XPS to PDF',
icon: 'ph-scan',
subtitle: 'Convert XPS/OXPS documents to PDF.',
},
{
href: import.meta.env.BASE_URL + 'mobi-to-pdf.html',
name: 'MOBI to PDF',
icon: 'ph-book-open-text',
subtitle: 'Convert MOBI e-books to PDF.',
},
{
href: import.meta.env.BASE_URL + 'epub-to-pdf.html',
name: 'EPUB to PDF',
icon: 'ph-book-open-text',
subtitle: 'Convert EPUB e-books to PDF.',
},
{
href: import.meta.env.BASE_URL + 'fb2-to-pdf.html',
name: 'FB2 to PDF',
icon: 'ph-book-bookmark',
subtitle: 'Convert FictionBook (FB2) e-books to PDF.',
},
{
href: import.meta.env.BASE_URL + 'cbz-to-pdf.html',
name: 'CBZ to PDF',
icon: 'ph-book-open',
subtitle: 'Convert comic book archives (CBZ/CBR) to PDF.',
},
{
href: import.meta.env.BASE_URL + 'wpd-to-pdf.html',
name: 'WPD to PDF',
icon: 'ph-file-text',
subtitle: 'Convert WordPerfect documents (WPD) to PDF.',
},
{
href: import.meta.env.BASE_URL + 'wps-to-pdf.html',
name: 'WPS to PDF',
icon: 'ph-file-text',
subtitle: 'Convert WPS Office documents to PDF.',
},
{
href: import.meta.env.BASE_URL + 'xml-to-pdf.html',
name: 'XML to PDF',
icon: 'ph-file-code',
subtitle: 'Convert XML documents to PDF.',
},
{
href: import.meta.env.BASE_URL + 'pages-to-pdf.html',
name: 'Pages to PDF',
icon: 'ph-file-text',
subtitle: 'Convert Apple Pages documents to PDF.',
},
{
href: import.meta.env.BASE_URL + 'odg-to-pdf.html',
name: 'ODG to PDF',
icon: 'ph-image',
subtitle: 'Convert OpenDocument Graphics (ODG) to PDF.',
},
{
href: import.meta.env.BASE_URL + 'ods-to-pdf.html',
name: 'ODS to PDF',
icon: 'ph-table',
subtitle: 'Convert OpenDocument Spreadsheet (ODS) to PDF.',
},
{
href: import.meta.env.BASE_URL + 'odp-to-pdf.html',
name: 'ODP to PDF',
icon: 'ph-presentation',
subtitle: 'Convert OpenDocument Presentation (ODP) to PDF.',
},
{
href: import.meta.env.BASE_URL + 'pub-to-pdf.html',
name: 'PUB to PDF',
icon: 'ph-book-open',
subtitle: 'Convert Microsoft Publisher (PUB) to PDF.',
},
{
href: import.meta.env.BASE_URL + 'vsd-to-pdf.html',
name: 'VSD to PDF',
icon: 'ph-git-branch',
subtitle: 'Convert Microsoft Visio (VSD, VSDX) to PDF.',
},
{
href: import.meta.env.BASE_URL + 'psd-to-pdf.html',
name: 'PSD to PDF',
icon: 'ph-image',
subtitle: 'Convert Adobe Photoshop (PSD) files to PDF. Multiple files supported.',
},
],
},
{
@@ -245,45 +377,93 @@ export const categories = [
{
href: import.meta.env.BASE_URL + 'pdf-to-jpg.html',
name: 'PDF to JPG',
icon: 'file-image',
icon: 'ph-file-image',
subtitle: 'Convert each PDF page into a JPG image.',
},
{
href: import.meta.env.BASE_URL + 'pdf-to-png.html',
name: 'PDF to PNG',
icon: 'file-image',
icon: 'ph-file-image',
subtitle: 'Convert each PDF page into a PNG image.',
},
{
href: import.meta.env.BASE_URL + 'pdf-to-webp.html',
name: 'PDF to WebP',
icon: 'file-image',
icon: 'ph-file-image',
subtitle: 'Convert each PDF page into a WebP image.',
},
{
href: import.meta.env.BASE_URL + 'pdf-to-bmp.html',
name: 'PDF to BMP',
icon: 'file-image',
icon: 'ph-file-image',
subtitle: 'Convert each PDF page into a BMP image.',
},
{
href: import.meta.env.BASE_URL + 'pdf-to-tiff.html',
name: 'PDF to TIFF',
icon: 'file-image',
icon: 'ph-file-image',
subtitle: 'Convert each PDF page into a TIFF image.',
},
{
href: import.meta.env.BASE_URL + 'pdf-to-svg.html',
name: 'PDF to SVG',
icon: 'ph-file-code',
subtitle: 'Convert each PDF page into a scalable vector graphic.',
},
{
href: import.meta.env.BASE_URL + 'pdf-to-csv.html',
name: 'PDF to CSV',
icon: 'ph-file-csv',
subtitle: 'Extract tables from PDF and convert to CSV format.',
},
{
href: import.meta.env.BASE_URL + 'pdf-to-excel.html',
name: 'PDF to Excel',
icon: 'ph-microsoft-excel-logo',
subtitle: 'Extract tables from PDF and convert to Excel (XLSX).',
},
{
href: import.meta.env.BASE_URL + 'pdf-to-greyscale.html',
name: 'PDF to Greyscale',
icon: 'palette',
icon: 'ph-palette',
subtitle: 'Convert all colors to black and white.',
},
{
href: import.meta.env.BASE_URL + 'pdf-to-json.html',
name: 'PDF to JSON',
icon: 'file-code',
icon: 'ph-file-code',
subtitle: 'Convert PDF files to JSON format.',
},
{
href: import.meta.env.BASE_URL + 'pdf-to-docx.html',
name: 'PDF to Word',
icon: 'ph-microsoft-word-logo',
subtitle: 'Convert PDF files to editable Word documents.',
},
{
href: import.meta.env.BASE_URL + 'extract-images.html',
name: 'Extract Images',
icon: 'ph-download-simple',
subtitle: 'Extract all embedded images from your PDF files.',
},
{
href: import.meta.env.BASE_URL + 'pdf-to-markdown.html',
name: 'PDF to Markdown',
icon: 'ph-markdown-logo',
subtitle: 'Convert PDF text and tables to Markdown format.',
},
{
href: import.meta.env.BASE_URL + 'prepare-pdf-for-ai.html',
name: 'Prepare PDF for AI',
icon: 'ph-sparkle',
subtitle: 'Extract PDF content as LlamaIndex JSON for RAG/LLM pipelines.',
},
{
href: import.meta.env.BASE_URL + 'pdf-to-text.html',
name: 'PDF to Text',
icon: 'ph-text-aa',
subtitle: 'Extract text from PDF files and save as plain text (.txt).',
},
],
},
{
@@ -292,133 +472,157 @@ export const categories = [
{
href: import.meta.env.BASE_URL + 'ocr-pdf.html',
name: 'OCR PDF',
icon: 'scan-text',
icon: 'ph-barcode',
subtitle: 'Make a PDF searchable and copyable.',
},
{
href: import.meta.env.BASE_URL + 'merge-pdf.html',
name: 'Merge PDF',
icon: 'combine',
icon: 'ph-browsers',
subtitle: 'Combine multiple PDFs into one file.',
},
{
href: import.meta.env.BASE_URL + 'alternate-merge.html',
name: 'Alternate & Mix Pages',
icon: 'shuffle',
icon: 'ph-shuffle',
subtitle: 'Merge PDFs by alternating pages from each PDF. Preserves Bookmarks',
},
{
href: import.meta.env.BASE_URL + 'organize-pdf.html',
name: 'Organize & Duplicate',
icon: 'files',
icon: 'ph-files',
subtitle: 'Duplicate, reorder, and delete pages.',
},
{
href: import.meta.env.BASE_URL + 'add-attachments.html',
name: 'Add Attachments',
icon: 'paperclip',
icon: 'ph-paperclip',
subtitle: 'Embed one or more files into your PDF.',
},
{
href: import.meta.env.BASE_URL + 'extract-attachments.html',
name: 'Extract Attachments',
icon: 'download',
icon: 'ph-download',
subtitle: 'Extract all embedded files from PDF(s) as a ZIP.',
},
{
href: import.meta.env.BASE_URL + 'edit-attachments.html',
name: 'Edit Attachments',
icon: 'file-edit',
icon: 'ph-paperclip-horizontal',
subtitle: 'View or remove attachments in your PDF.',
},
{
href: import.meta.env.BASE_URL + 'pdf-multi-tool.html',
name: 'PDF Multi Tool',
icon: 'pencil-ruler',
icon: 'ph-pencil-ruler',
subtitle: 'Full-featured PDF editor with page management.',
},
{
href: import.meta.env.BASE_URL + 'pdf-layers.html',
name: 'PDF OCG',
icon: 'ph-stack-simple',
subtitle: 'View, toggle, add, and delete OCG layers in your PDF.',
},
{
href: import.meta.env.BASE_URL + 'extract-tables.html',
name: 'Extract Tables',
icon: 'ph-table',
subtitle: 'Extract tables from PDFs as CSV, JSON, or Markdown.',
},
{
href: import.meta.env.BASE_URL + 'split-pdf.html',
name: 'Split PDF',
icon: 'scissors',
icon: 'ph-scissors',
subtitle: 'Extract a range of pages into a new PDF.',
},
{
href: import.meta.env.BASE_URL + 'divide-pages.html',
name: 'Divide Pages',
icon: 'table-columns-split',
icon: 'ph-columns',
subtitle: 'Divide pages horizontally or vertically.',
},
{
href: import.meta.env.BASE_URL + 'extract-pages.html',
name: 'Extract Pages',
icon: 'ungroup',
icon: 'ph-squares-four',
subtitle: 'Save a selection of pages as new files.',
},
{
href: import.meta.env.BASE_URL + 'delete-pages.html',
name: 'Delete Pages',
icon: 'trash-2',
icon: 'ph-trash',
subtitle: 'Remove specific pages from your document.',
},
{
href: import.meta.env.BASE_URL + 'add-blank-page.html',
name: 'Add Blank Page',
icon: 'file-plus-2',
icon: 'ph-file-plus',
subtitle: 'Insert an empty page anywhere in your PDF.',
},
{
href: import.meta.env.BASE_URL + 'reverse-pages.html',
name: 'Reverse Pages',
icon: 'arrow-down-z-a',
icon: 'ph-sort-descending',
subtitle: 'Flip the order of all pages in your document.',
},
{
href: import.meta.env.BASE_URL + 'rotate-pdf.html',
name: 'Rotate PDF',
icon: 'rotate-cw',
icon: 'ph-arrow-clockwise',
subtitle: 'Turn pages in 90-degree increments.',
},
{
href: import.meta.env.BASE_URL + 'rotate-custom.html',
name: 'Rotate by Custom Degrees',
icon: 'ph-arrows-clockwise',
subtitle: 'Rotate pages by any custom angle.',
},
{
href: import.meta.env.BASE_URL + 'n-up-pdf.html',
name: 'N-Up PDF',
icon: 'layout-grid',
icon: 'ph-squares-four',
subtitle: 'Arrange multiple pages onto a single sheet.',
},
{
href: import.meta.env.BASE_URL + 'pdf-booklet.html',
name: 'PDF Booklet',
icon: 'ph-book-open',
subtitle: 'Rearrange pages for double-sided booklet printing.',
},
{
href: import.meta.env.BASE_URL + 'combine-single-page.html',
name: 'Combine to Single Page',
icon: 'unfold-vertical',
icon: 'ph-arrows-out-line-vertical',
subtitle: 'Stitch all pages into one continuous scroll.',
},
{
href: import.meta.env.BASE_URL + 'view-metadata.html',
name: 'View Metadata',
icon: 'info',
icon: 'ph-info',
subtitle: 'Inspect the hidden properties of your PDF.',
},
{
href: import.meta.env.BASE_URL + 'edit-metadata.html',
name: 'Edit Metadata',
icon: 'file-cog',
icon: 'ph-file-code',
subtitle: 'Change the author, title, and other properties.',
},
{
href: import.meta.env.BASE_URL + 'pdf-to-zip.html',
name: 'PDFs to ZIP',
icon: 'stretch-horizontal',
icon: 'ph-file-zip',
subtitle: 'Package multiple PDF files into a ZIP archive.',
},
{
href: import.meta.env.BASE_URL + 'compare-pdfs.html',
name: 'Compare PDFs',
icon: 'git-compare',
icon: 'ph-git-diff',
subtitle: 'Compare two PDFs side by side.',
},
{
href: import.meta.env.BASE_URL + 'posterize-pdf.html',
name: 'Posterize PDF',
icon: 'notepad-text-dashed',
icon: 'ph-notepad',
subtitle: 'Split a large page into multiple smaller pages.',
},
],
@@ -429,40 +633,52 @@ export const categories = [
{
href: import.meta.env.BASE_URL + 'compress-pdf.html',
name: 'Compress PDF',
icon: 'zap',
icon: 'ph-lightning',
subtitle: 'Reduce the file size of your PDF.',
},
{
href: import.meta.env.BASE_URL + 'pdf-to-pdfa.html',
name: 'PDF to PDF/A',
icon: 'ph-archive',
subtitle: 'Convert PDF to PDF/A for long-term archiving.',
},
{
href: import.meta.env.BASE_URL + 'fix-page-size.html',
name: 'Fix Page Size',
icon: 'ruler-dimension-line',
icon: 'ph-ruler',
subtitle: 'Standardize all pages to a uniform size.',
},
{
href: import.meta.env.BASE_URL + 'linearize-pdf.html',
name: 'Linearize PDF',
icon: 'gauge',
icon: 'ph-gauge',
subtitle: 'Optimize PDF for fast web viewing.',
},
{
href: import.meta.env.BASE_URL + 'page-dimensions.html',
name: 'Page Dimensions',
icon: 'ruler',
icon: 'ph-ruler',
subtitle: 'Analyze page size, orientation, and units.',
},
{
href: import.meta.env.BASE_URL + 'remove-restrictions.html',
name: 'Remove Restrictions',
icon: 'unlink',
icon: 'ph-link-break',
subtitle:
'Remove password protection and security restrictions associated with digitally signed PDF files.',
},
{
href: import.meta.env.BASE_URL + 'repair-pdf.html',
name: 'Repair PDF',
icon: 'wrench',
icon: 'ph-wrench',
subtitle: 'Recover data from corrupted or damaged PDF files.',
},
{
href: import.meta.env.BASE_URL + 'rasterize-pdf.html',
name: 'Rasterize PDF',
icon: 'ph-image',
subtitle: 'Convert PDF to image-based PDF. Flatten layers and remove selectable text.',
},
],
},
{
@@ -471,39 +687,51 @@ export const categories = [
{
href: import.meta.env.BASE_URL + 'encrypt-pdf.html',
name: 'Encrypt PDF',
icon: 'lock',
icon: 'ph-lock',
subtitle: 'Lock your PDF by adding a password.',
},
{
href: import.meta.env.BASE_URL + 'sanitize-pdf.html',
name: 'Sanitize PDF',
icon: 'brush-cleaning',
icon: 'ph-broom',
subtitle: 'Remove metadata, annotations, scripts, and more.',
},
{
href: import.meta.env.BASE_URL + 'decrypt-pdf.html',
name: 'Decrypt PDF',
icon: 'unlock',
icon: 'ph-lock-open',
subtitle: 'Unlock PDF by removing password protection.',
},
{
href: import.meta.env.BASE_URL + 'flatten-pdf.html',
name: 'Flatten PDF',
icon: 'layers',
icon: 'ph-stack',
subtitle: 'Make form fields and annotations non-editable.',
},
{
href: import.meta.env.BASE_URL + 'remove-metadata.html',
name: 'Remove Metadata',
icon: 'file-x',
icon: 'ph-file-x',
subtitle: 'Strip hidden data from your PDF.',
},
{
href: import.meta.env.BASE_URL + 'change-permissions.html',
name: 'Change Permissions',
icon: 'shield-check',
icon: 'ph-shield-check',
subtitle: 'Set or change user permissions on a PDF.',
},
{
href: import.meta.env.BASE_URL + 'digital-sign-pdf.html',
name: 'Digital Signature',
icon: 'ph-certificate',
subtitle: 'Add a cryptographic digital signature using X.509 certificates.',
},
{
href: import.meta.env.BASE_URL + 'validate-signature-pdf.html',
name: 'Validate Signature',
icon: 'ph-seal-check',
subtitle: 'Verify digital signatures and view certificate details.',
},
],
},
];

View File

@@ -0,0 +1,75 @@
/**
* WASM CDN Configuration
*
* Centralized configuration for loading WASM files from jsDelivr CDN or local paths.
* Supports environment-based toggling and automatic fallback mechanism.
*/
const USE_CDN = import.meta.env.VITE_USE_CDN === 'true';
import { CDN_URLS, PACKAGE_VERSIONS } from '../const/cdn-version';
const LOCAL_PATHS = {
ghostscript: import.meta.env.BASE_URL + 'ghostscript-wasm/',
pymupdf: import.meta.env.BASE_URL + 'pymupdf-wasm/',
} as const;
export type WasmPackage = 'ghostscript' | 'pymupdf';
export function getWasmBaseUrl(packageName: WasmPackage): string {
if (USE_CDN) {
return CDN_URLS[packageName];
}
return LOCAL_PATHS[packageName];
}
export function getWasmFallbackUrl(packageName: WasmPackage): string {
return LOCAL_PATHS[packageName];
}
export function isCdnEnabled(): boolean {
return USE_CDN;
}
/**
* Fetch a file with automatic CDN → local fallback
* @param packageName - WASM package name
* @param fileName - File name relative to package base
* @returns Response object
*/
export async function fetchWasmFile(
packageName: WasmPackage,
fileName: string
): Promise<Response> {
const cdnUrl = CDN_URLS[packageName] + fileName;
const localUrl = LOCAL_PATHS[packageName] + fileName;
if (USE_CDN) {
try {
console.log(`[WASM CDN] Fetching from CDN: ${cdnUrl}`);
const response = await fetch(cdnUrl);
if (response.ok) {
return response;
}
console.warn(`[WASM CDN] CDN fetch failed with status ${response.status}, trying local fallback...`);
} catch (error) {
console.warn(`[WASM CDN] CDN fetch error:`, error, `- trying local fallback...`);
}
}
const response = await fetch(localUrl);
if (!response.ok) {
throw new Error(`Failed to fetch ${fileName}: HTTP ${response.status}`);
}
return response;
}
// use this to debug
export function getWasmConfigInfo() {
return {
cdnEnabled: USE_CDN,
packageVersions: PACKAGE_VERSIONS,
cdnUrls: CDN_URLS,
localPaths: LOCAL_PATHS,
};
}

View File

@@ -0,0 +1,9 @@
export const PACKAGE_VERSIONS = {
ghostscript: '0.1.0',
pymupdf: '0.1.9',
} as const;
export const CDN_URLS = {
ghostscript: `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@${PACKAGE_VERSIONS.ghostscript}/assets/`,
pymupdf: `https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@${PACKAGE_VERSIONS.pymupdf}/assets/`,
} as const;

View File

@@ -1,23 +0,0 @@
// Simple FAQ accordion handler for standalone pages
window.addEventListener('load', () => {
const faqAccordion = document.getElementById('faq-accordion');
if (faqAccordion) {
faqAccordion.addEventListener('click', (e) => {
const questionButton = (e.target as HTMLElement).closest('.faq-question');
if (!questionButton) return;
const faqItem = questionButton.parentElement;
const answer = faqItem?.querySelector('.faq-answer') as HTMLElement;
if (!faqItem || !answer) return;
faqItem.classList.toggle('open');
if (faqItem.classList.contains('open')) {
answer.style.maxHeight = answer.scrollHeight + 'px';
} else {
answer.style.maxHeight = '0px';
}
});
}
});

View File

@@ -170,14 +170,14 @@ async function handleSinglePdfUpload(toolId, file) {
if (rotateAllDecrementBtn) {
rotateAllDecrementBtn.onclick = () => {
let current = parseInt(rotateAllCustomInput.value) || 0;
const current = parseInt(rotateAllCustomInput.value) || 0;
rotateAllCustomInput.value = (current - 1).toString();
};
}
if (rotateAllIncrementBtn) {
rotateAllIncrementBtn.onclick = () => {
let current = parseInt(rotateAllCustomInput.value) || 0;
const current = parseInt(rotateAllCustomInput.value) || 0;
rotateAllCustomInput.value = (current + 1).toString();
};
}
@@ -262,7 +262,7 @@ async function handleSinglePdfUpload(toolId, file) {
const infoSection = createSection('Info Dictionary');
if (info && Object.keys(info).length > 0) {
for (const key in info) {
let value = info[key];
const value = info[key];
let displayValue;
if (value === null || typeof value === 'undefined') {

View File

@@ -31,38 +31,38 @@ export const getLanguageFromUrl = (): SupportedLanguage => {
let initialized = false;
export const initI18n = async (): Promise<typeof i18next> => {
if (initialized) return i18next;
if (initialized) return i18next;
const currentLang = getLanguageFromUrl();
const currentLang = getLanguageFromUrl();
await i18next
.use(HttpBackend)
.use(LanguageDetector)
.init({
lng: currentLang,
fallbackLng: 'en',
supportedLngs: supportedLanguages as unknown as string[],
ns: ['common', 'tools'],
defaultNS: 'common',
backend: {
loadPath: `${import.meta.env.BASE_URL}locales/{{lng}}/{{ns}}.json`,
},
detection: {
order: ['path', 'localStorage', 'navigator'],
lookupFromPathIndex: 0,
caches: ['localStorage'],
},
interpolation: {
escapeValue: false,
},
});
await i18next
.use(HttpBackend)
.use(LanguageDetector)
.init({
lng: currentLang,
fallbackLng: 'en',
supportedLngs: supportedLanguages as unknown as string[],
ns: ['common', 'tools'],
defaultNS: 'common',
backend: {
loadPath: `${import.meta.env.BASE_URL.replace(/\/?$/, '/')}locales/{{lng}}/{{ns}}.json`,
},
detection: {
order: ['path', 'localStorage', 'navigator'],
lookupFromPathIndex: 0,
caches: ['localStorage'],
},
interpolation: {
escapeValue: false,
},
});
initialized = true;
return i18next;
initialized = true;
return i18next;
};
export const t = (key: string, options?: Record<string, unknown>): string => {
return i18next.t(key, options);
return i18next.t(key, options);
};
export const changeLanguage = (lang: SupportedLanguage): void => {
@@ -86,37 +86,37 @@ export const changeLanguage = (lang: SupportedLanguage): void => {
// Apply translations to all elements with data-i18n attribute
export const applyTranslations = (): void => {
document.querySelectorAll('[data-i18n]').forEach((element) => {
const key = element.getAttribute('data-i18n');
if (key) {
const translation = t(key);
if (translation && translation !== key) {
element.textContent = translation;
}
}
});
document.querySelectorAll('[data-i18n]').forEach((element) => {
const key = element.getAttribute('data-i18n');
if (key) {
const translation = t(key);
if (translation && translation !== key) {
element.textContent = translation;
}
}
});
document.querySelectorAll('[data-i18n-placeholder]').forEach((element) => {
const key = element.getAttribute('data-i18n-placeholder');
if (key && element instanceof HTMLInputElement) {
const translation = t(key);
if (translation && translation !== key) {
element.placeholder = translation;
}
}
});
document.querySelectorAll('[data-i18n-placeholder]').forEach((element) => {
const key = element.getAttribute('data-i18n-placeholder');
if (key && element instanceof HTMLInputElement) {
const translation = t(key);
if (translation && translation !== key) {
element.placeholder = translation;
}
}
});
document.querySelectorAll('[data-i18n-title]').forEach((element) => {
const key = element.getAttribute('data-i18n-title');
if (key) {
const translation = t(key);
if (translation && translation !== key) {
(element as HTMLElement).title = translation;
}
}
});
document.querySelectorAll('[data-i18n-title]').forEach((element) => {
const key = element.getAttribute('data-i18n-title');
if (key) {
const translation = t(key);
if (translation && translation !== key) {
(element as HTMLElement).title = translation;
}
}
});
document.documentElement.lang = i18next.language;
document.documentElement.lang = i18next.language;
};
export const rewriteLinks = (): void => {
@@ -136,7 +136,7 @@ export const rewriteLinks = (): void => {
return;
}
if (href.match(/^\/(en|de|zh|vi|tr)\//)) {
if (href.match(/^\/(en|de|zh|vi|tr|id)\//)) {
return;
}
let newHref: string;

View File

@@ -1,3 +1,15 @@
export { initI18n, t, changeLanguage, applyTranslations, rewriteLinks, getLanguageFromUrl, supportedLanguages, languageNames } from './i18n';
export {
initI18n,
t,
changeLanguage,
applyTranslations,
rewriteLinks,
getLanguageFromUrl,
supportedLanguages,
languageNames,
} from './i18n';
export type { SupportedLanguage } from './i18n';
export { createLanguageSwitcher, injectLanguageSwitcher } from './language-switcher';
export {
createLanguageSwitcher,
injectLanguageSwitcher,
} from './language-switcher';

View File

@@ -1,3 +1,4 @@
import { AddAttachmentState } from '@/types';
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
@@ -5,12 +6,6 @@ import { PDFDocument as PDFLibDocument } from 'pdf-lib';
const worker = new Worker(import.meta.env.BASE_URL + 'workers/add-attachments.worker.js');
interface AddAttachmentState {
file: File | null;
pdfDoc: PDFLibDocument | null;
attachments: File[];
}
const pageState: AddAttachmentState = {
file: null,
pdfDoc: null,

View File

@@ -2,11 +2,8 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import { AddBlankPageState } from '@/types';
interface AddBlankPageState {
file: File | null;
pdfDoc: PDFLibDocument | null;
}
const pageState: AddBlankPageState = {
file: null,

View File

@@ -35,8 +35,7 @@ function resetState() {
viewerContainer.style.aspectRatio = ''
}
// Revert container width only if NOT in full width mode
const isFullWidth = localStorage.getItem('fullWidthMode') === 'true'
const isFullWidth = localStorage.getItem('fullWidthMode') !== 'false'
if (toolUploader && !isFullWidth) {
toolUploader.classList.remove('max-w-6xl')
toolUploader.classList.add('max-w-2xl')
@@ -56,8 +55,8 @@ function updateFileList() {
fileListDiv.classList.remove('hidden')
fileListDiv.innerHTML = ''
// Expand container width for viewer if NOT in full width mode
const isFullWidth = localStorage.getItem('fullWidthMode') === 'true'
// Expand container width for viewer if NOT in full width mode (default to true if not set)
const isFullWidth = localStorage.getItem('fullWidthMode') !== 'false'
if (toolUploader && !isFullWidth) {
toolUploader.classList.remove('max-w-2xl')
toolUploader.classList.add('max-w-6xl')

View File

@@ -2,13 +2,9 @@ import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { downloadFile, hexToRgb, formatBytes, readFileAsArrayBuffer } from '../utils/helpers.js';
import { PDFDocument as PDFLibDocument, rgb, degrees, StandardFonts } from 'pdf-lib';
import { AddWatermarkState } from '@/types';
interface PageState {
file: File | null;
pdfDoc: PDFLibDocument | null;
}
const pageState: PageState = { file: null, pdfDoc: null };
const pageState: AddWatermarkState = { file: null, pdfDoc: null };
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);

View File

@@ -1,14 +1,9 @@
import { AlternateMergeState } from '@/types';
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes, getPDFDocument } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
import Sortable from 'sortablejs';
interface AlternateMergeState {
files: File[];
pdfBytes: Map<string, ArrayBuffer>;
pdfDocs: Map<string, any>;
}
const pageState: AlternateMergeState = {
files: [],
pdfBytes: new Map(),

View File

@@ -2,9 +2,9 @@ import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { downloadFile, hexToRgb, formatBytes } from '../utils/helpers.js';
import { PDFDocument as PDFLibDocument, rgb } from 'pdf-lib';
import { BackgroundColorState } from '@/types';
interface PageState { file: File | null; pdfDoc: PDFLibDocument | null; }
const pageState: PageState = { file: null, pdfDoc: null };
const pageState: BackgroundColorState = { file: null, pdfDoc: null };
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializePage); }
else { initializePage(); }

View File

@@ -771,8 +771,8 @@ let searchQuery = '';
let csvBookmarks = null;
let jsonBookmarks = null;
let batchMode = false;
let selectedBookmarks = new Set();
let collapsedNodes = new Set();
const selectedBookmarks = new Set();
const collapsedNodes = new Set();
const colorClasses = {
red: 'bg-red-100 border-red-300',
@@ -1126,7 +1126,7 @@ async function renderPage(num, zoom = null, destX = null, destY = null) {
const dpr = window.devicePixelRatio || 1;
let viewport = page.getViewport({ scale: zoomScale });
const viewport = page.getViewport({ scale: zoomScale });
currentViewport = viewport;
canvas.height = viewport.height * dpr;

View File

@@ -0,0 +1,337 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
import JSZip from 'jszip';
import { PDFDocument } from 'pdf-lib';
const EXTENSIONS = ['.cbz', '.cbr'];
const TOOL_NAME = 'CBZ';
const ALL_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.avif', '.jxl', '.heic', '.heif'];
const IMAGE_SIGNATURES = {
jpeg: [0xFF, 0xD8, 0xFF],
png: [0x89, 0x50, 0x4E, 0x47],
gif: [0x47, 0x49, 0x46],
bmp: [0x42, 0x4D],
webp: [0x52, 0x49, 0x46, 0x46],
avif: [0x00, 0x00, 0x00],
};
function matchesSignature(data: Uint8Array, signature: number[], offset = 0): boolean {
for (let i = 0; i < signature.length; i++) {
if (data[offset + i] !== signature[i]) return false;
}
return true;
}
function detectImageFormat(data: Uint8Array): 'jpeg' | 'png' | 'gif' | 'bmp' | 'webp' | 'avif' | 'unknown' {
if (data.length < 12) return 'unknown';
if (matchesSignature(data, IMAGE_SIGNATURES.jpeg)) return 'jpeg';
if (matchesSignature(data, IMAGE_SIGNATURES.png)) return 'png';
if (matchesSignature(data, IMAGE_SIGNATURES.gif)) return 'gif';
if (matchesSignature(data, IMAGE_SIGNATURES.bmp)) return 'bmp';
if (matchesSignature(data, IMAGE_SIGNATURES.webp) &&
data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50) {
return 'webp';
}
if (data[4] === 0x66 && data[5] === 0x74 && data[6] === 0x79 && data[7] === 0x70) {
const brand = String.fromCharCode(data[8], data[9], data[10], data[11]);
if (brand === 'avif' || brand === 'avis' || brand === 'mif1' || brand === 'miaf') {
return 'avif';
}
}
return 'unknown';
}
function isCbzFile(filename: string): boolean {
return filename.toLowerCase().endsWith('.cbz');
}
async function convertImageToPng(imageData: ArrayBuffer, filename: string): Promise<Blob> {
return new Promise((resolve, reject) => {
const blob = new Blob([imageData]);
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
URL.revokeObjectURL(url);
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
canvas.toBlob((pngBlob) => {
URL.revokeObjectURL(url);
if (pngBlob) {
resolve(pngBlob);
} else {
reject(new Error(`Failed to convert ${filename} to PNG`));
}
}, 'image/png');
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error(`Failed to load image: ${filename}`));
};
img.src = url;
});
}
async function convertCbzToPdf(file: File): Promise<Blob> {
const zip = await JSZip.loadAsync(file);
const pdfDoc = await PDFDocument.create();
const imageFiles = Object.keys(zip.files)
.filter(name => {
if (zip.files[name].dir) return false;
const ext = name.toLowerCase().substring(name.lastIndexOf('.'));
return ALL_IMAGE_EXTENSIONS.includes(ext);
})
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
for (const filename of imageFiles) {
const zipEntry = zip.files[filename];
const imageData = await zipEntry.async('arraybuffer');
const dataArray = new Uint8Array(imageData);
const actualFormat = detectImageFormat(dataArray);
let imageBytes: Uint8Array;
let embedMethod: 'png' | 'jpg';
if (actualFormat === 'jpeg') {
imageBytes = dataArray;
embedMethod = 'jpg';
} else if (actualFormat === 'png') {
imageBytes = dataArray;
embedMethod = 'png';
} else {
const pngBlob = await convertImageToPng(imageData, filename);
imageBytes = new Uint8Array(await pngBlob.arrayBuffer());
embedMethod = 'png';
}
const image = embedMethod === 'png'
? await pdfDoc.embedPng(imageBytes)
: await pdfDoc.embedJpg(imageBytes);
const page = pdfDoc.addPage([image.width, image.height]);
page.drawImage(image, {
x: 0,
y: 0,
width: image.width,
height: image.height,
});
}
const pdfBytes = await pdfDoc.save();
return new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' });
}
async function convertCbrToPdf(file: File): Promise<Blob> {
const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
await pymupdf.load();
return await pymupdf.convertToPdf(file, { filetype: 'cbz' });
}
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!fileDisplayArea || !processBtn || !fileControls) return;
if (state.files.length > 0) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
fileControls.classList.remove('hidden');
processBtn.classList.remove('hidden');
(processBtn as HTMLButtonElement).disabled = false;
} else {
fileDisplayArea.innerHTML = '';
fileControls.classList.add('hidden');
processBtn.classList.add('hidden');
(processBtn as HTMLButtonElement).disabled = true;
}
};
const resetState = () => {
state.files = [];
state.pdfDoc = null;
updateUI();
};
const convertToPdf = async () => {
try {
if (state.files.length === 0) {
showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`);
return;
}
if (state.files.length === 1) {
const originalFile = state.files[0];
showLoader(`Converting ${originalFile.name}...`);
let pdfBlob: Blob;
if (isCbzFile(originalFile.name)) {
pdfBlob = await convertCbzToPdf(originalFile);
} else {
pdfBlob = await convertCbrToPdf(originalFile);
}
const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf';
downloadFile(pdfBlob, fileName);
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${originalFile.name} to PDF.`,
'success',
() => resetState()
);
} else {
showLoader('Converting files...');
const outputZip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
let pdfBlob: Blob;
if (isCbzFile(file.name)) {
pdfBlob = await convertCbzToPdf(file);
} else {
pdfBlob = await convertCbrToPdf(file);
}
const baseName = file.name.replace(/\.[^.]+$/, '');
const pdfBuffer = await pdfBlob.arrayBuffer();
outputZip.file(`${baseName}.pdf`, pdfBuffer);
}
const zipBlob = await outputZip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'comic-converted.zip');
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`,
'success',
() => resetState()
);
}
} catch (e: any) {
console.error(`[${TOOL_NAME}2PDF] ERROR:`, e);
hideLoader();
showAlert('Error', `An error occurred during conversion. Error: ${e.message}`);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
state.files = [...state.files, ...Array.from(files)];
updateUI();
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const validFiles = Array.from(files).filter(f => {
const name = f.name.toLowerCase();
return EXTENSIONS.some(ext => name.endsWith(ext));
});
if (validFiles.length > 0) {
const dataTransfer = new DataTransfer();
validFiles.forEach(f => dataTransfer.items.add(f));
handleFileSelect(dataTransfer.files);
}
}
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput.click();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', () => {
resetState();
});
}
if (processBtn) {
processBtn.addEventListener('click', convertToPdf);
}
});

View File

@@ -1,12 +1,9 @@
import { showAlert } from '../ui.js';
import { downloadFile, formatBytes, initializeQpdf, readFileAsArrayBuffer } from '../utils/helpers.js';
import { icons, createIcons } from 'lucide';
import { ChangePermissionsState } from '@/types';
interface PageState {
file: File | null;
}
const pageState: PageState = {
const pageState: ChangePermissionsState = {
file: null,
};

View File

@@ -3,15 +3,11 @@ import { downloadFile, formatBytes, hexToRgb, getPDFDocument } from '../utils/he
import { createIcons, icons } from 'lucide';
import { PDFDocument as PDFLibDocument, rgb } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
import { CombineSinglePageState } from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
interface CombineState {
file: File | null;
pdfDoc: PDFLibDocument | null;
}
const pageState: CombineState = {
const pageState: CombineSinglePageState = {
file: null,
pdfDoc: null,
};

View File

@@ -2,17 +2,10 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
import { getPDFDocument } from '../utils/helpers.js';
import { icons, createIcons } from 'lucide';
import * as pdfjsLib from 'pdfjs-dist';
import { CompareState } from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
interface CompareState {
pdfDoc1: pdfjsLib.PDFDocumentProxy | null;
pdfDoc2: pdfjsLib.PDFDocumentProxy | null;
currentPage: number;
viewMode: 'overlay' | 'side-by-side';
isSyncScroll: boolean;
}
const pageState: CompareState = {
pdfDoc1: null,
pdfDoc2: null,

View File

@@ -7,187 +7,115 @@ import {
} from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { PDFDocument } from 'pdf-lib';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
import * as pdfjsLib from 'pdfjs-dist';
import { PDFDocument, PDFName, PDFDict, PDFStream, PDFNumber } from 'pdf-lib';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
function dataUrlToBytes(dataUrl: any) {
const base64 = dataUrl.split(',')[1];
const binaryString = atob(base64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
const CONDENSE_PRESETS = {
light: {
images: { quality: 90, dpiTarget: 150, dpiThreshold: 200 },
scrub: { metadata: false, thumbnails: true },
subsetFonts: true,
},
balanced: {
images: { quality: 75, dpiTarget: 96, dpiThreshold: 150 },
scrub: { metadata: true, thumbnails: true },
subsetFonts: true,
},
aggressive: {
images: { quality: 50, dpiTarget: 72, dpiThreshold: 100 },
scrub: { metadata: true, thumbnails: true, xmlMetadata: true },
subsetFonts: true,
},
extreme: {
images: { quality: 30, dpiTarget: 60, dpiThreshold: 96 },
scrub: { metadata: true, thumbnails: true, xmlMetadata: true },
subsetFonts: true,
},
};
const PHOTON_PRESETS = {
light: { scale: 2.0, quality: 0.85 },
balanced: { scale: 1.5, quality: 0.65 },
aggressive: { scale: 1.2, quality: 0.45 },
extreme: { scale: 1.0, quality: 0.25 },
};
async function performCondenseCompression(
fileBlob: Blob,
level: string,
customSettings?: {
imageQuality?: number;
dpiTarget?: number;
dpiThreshold?: number;
removeMetadata?: boolean;
subsetFonts?: boolean;
convertToGrayscale?: boolean;
removeThumbnails?: boolean;
}
return bytes;
}
) {
const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
await pymupdf.load();
async function performSmartCompression(arrayBuffer: any, settings: any) {
const pdfDoc = await PDFDocument.load(arrayBuffer, {
ignoreEncryption: true,
});
const pages = pdfDoc.getPages();
const preset = CONDENSE_PRESETS[level as keyof typeof CONDENSE_PRESETS] || CONDENSE_PRESETS.balanced;
if (settings.removeMetadata) {
try {
pdfDoc.setTitle('');
pdfDoc.setAuthor('');
pdfDoc.setSubject('');
pdfDoc.setKeywords([]);
pdfDoc.setCreator('');
pdfDoc.setProducer('');
} catch (e) {
console.warn('Could not remove metadata:', e);
}
}
const dpiTarget = customSettings?.dpiTarget ?? preset.images.dpiTarget;
const userThreshold = customSettings?.dpiThreshold ?? preset.images.dpiThreshold;
const dpiThreshold = Math.max(userThreshold, dpiTarget + 10);
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
const resources = page.node.Resources();
if (!resources) continue;
const xobjects = resources.lookup(PDFName.of('XObject'));
if (!(xobjects instanceof PDFDict)) continue;
for (const [key, value] of xobjects.entries()) {
const stream = pdfDoc.context.lookup(value);
if (
!(stream instanceof PDFStream) ||
stream.dict.get(PDFName.of('Subtype')) !== PDFName.of('Image')
)
continue;
try {
const imageBytes = stream.getContents();
if (imageBytes.length < settings.skipSize) continue;
const width =
stream.dict.get(PDFName.of('Width')) instanceof PDFNumber
? (stream.dict.get(PDFName.of('Width')) as PDFNumber).asNumber()
: 0;
const height =
stream.dict.get(PDFName.of('Height')) instanceof PDFNumber
? (stream.dict.get(PDFName.of('Height')) as PDFNumber).asNumber()
: 0;
const bitsPerComponent =
stream.dict.get(PDFName.of('BitsPerComponent')) instanceof PDFNumber
? (
stream.dict.get(PDFName.of('BitsPerComponent')) as PDFNumber
).asNumber()
: 8;
if (width > 0 && height > 0) {
let newWidth = width;
let newHeight = height;
const scaleFactor = settings.scaleFactor || 1.0;
newWidth = Math.floor(width * scaleFactor);
newHeight = Math.floor(height * scaleFactor);
if (newWidth > settings.maxWidth || newHeight > settings.maxHeight) {
const aspectRatio = newWidth / newHeight;
if (newWidth > newHeight) {
newWidth = Math.min(newWidth, settings.maxWidth);
newHeight = newWidth / aspectRatio;
} else {
newHeight = Math.min(newHeight, settings.maxHeight);
newWidth = newHeight * aspectRatio;
}
}
const minDim = settings.minDimension || 50;
if (newWidth < minDim || newHeight < minDim) continue;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = Math.floor(newWidth);
canvas.height = Math.floor(newHeight);
const img = new Image();
const imageUrl = URL.createObjectURL(
new Blob([new Uint8Array(imageBytes)])
);
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = imageUrl;
});
ctx.imageSmoothingEnabled = settings.smoothing !== false;
ctx.imageSmoothingQuality = settings.smoothingQuality || 'medium';
if (settings.grayscale) {
ctx.filter = 'grayscale(100%)';
} else if (settings.contrast) {
ctx.filter = `contrast(${settings.contrast}) brightness(${settings.brightness || 1})`;
}
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
let bestBytes = null;
let bestSize = imageBytes.length;
const jpegDataUrl = canvas.toDataURL('image/jpeg', settings.quality);
const jpegBytes = dataUrlToBytes(jpegDataUrl);
if (jpegBytes.length < bestSize) {
bestBytes = jpegBytes;
bestSize = jpegBytes.length;
}
if (settings.tryWebP) {
try {
const webpDataUrl = canvas.toDataURL(
'image/webp',
settings.quality
);
const webpBytes = dataUrlToBytes(webpDataUrl);
if (webpBytes.length < bestSize) {
bestBytes = webpBytes;
bestSize = webpBytes.length;
}
} catch (e) {
/* WebP not supported */
}
}
if (bestBytes && bestSize < imageBytes.length * settings.threshold) {
(stream as any).contents = bestBytes;
stream.dict.set(PDFName.of('Length'), PDFNumber.of(bestSize));
stream.dict.set(PDFName.of('Width'), PDFNumber.of(canvas.width));
stream.dict.set(PDFName.of('Height'), PDFNumber.of(canvas.height));
stream.dict.set(PDFName.of('Filter'), PDFName.of('DCTDecode'));
stream.dict.delete(PDFName.of('DecodeParms'));
stream.dict.set(PDFName.of('BitsPerComponent'), PDFNumber.of(8));
if (settings.grayscale) {
stream.dict.set(
PDFName.of('ColorSpace'),
PDFName.of('DeviceGray')
);
}
}
URL.revokeObjectURL(imageUrl);
}
} catch (error) {
console.warn('Skipping an uncompressible image in smart mode:', error);
}
}
}
const saveOptions = {
useObjectStreams: settings.useObjectStreams !== false,
addDefaultPage: false,
objectsPerTick: settings.objectsPerTick || 50,
const options = {
images: {
enabled: true,
quality: customSettings?.imageQuality ?? preset.images.quality,
dpiTarget,
dpiThreshold,
convertToGray: customSettings?.convertToGrayscale ?? false,
},
scrub: {
metadata: customSettings?.removeMetadata ?? preset.scrub.metadata,
thumbnails: customSettings?.removeThumbnails ?? preset.scrub.thumbnails,
xmlMetadata: (preset.scrub as any).xmlMetadata ?? false,
},
subsetFonts: customSettings?.subsetFonts ?? preset.subsetFonts,
save: {
garbage: 4 as const,
deflate: true,
clean: true,
useObjstms: true,
},
};
return await pdfDoc.save(saveOptions);
try {
const result = await pymupdf.compressPdf(fileBlob, options);
return result;
} catch (error: any) {
const errorMessage = error?.message || String(error);
if (errorMessage.includes('PatternType') || errorMessage.includes('pattern')) {
console.warn('[CompressPDF] Pattern error detected, retrying without image rewriting:', errorMessage);
const fallbackOptions = {
...options,
images: {
...options.images,
enabled: false,
},
};
const result = await pymupdf.compressPdf(fileBlob, fallbackOptions);
return { ...result, usedFallback: true };
}
throw new Error(`PDF compression failed: ${errorMessage}`);
}
}
async function performLegacyCompression(arrayBuffer: any, settings: any) {
async function performPhotonCompression(arrayBuffer: ArrayBuffer, level: string) {
const pdfJsDoc = await getPDFDocument({ data: arrayBuffer }).promise;
const newPdfDoc = await PDFDocument.create();
const settings = PHOTON_PRESETS[level as keyof typeof PHOTON_PRESETS] || PHOTON_PRESETS.balanced;
for (let i = 1; i <= pdfJsDoc.numPages; i++) {
const page = await pdfJsDoc.getPage(i);
@@ -197,13 +125,12 @@ async function performLegacyCompression(arrayBuffer: any, settings: any) {
canvas.height = viewport.height;
canvas.width = viewport.width;
await page.render({ canvasContext: context, viewport, canvas: canvas })
.promise;
await page.render({ canvasContext: context, viewport, canvas: canvas }).promise;
const jpegBlob = await new Promise((resolve) =>
canvas.toBlob(resolve, 'image/jpeg', settings.quality)
const jpegBlob = await new Promise<Blob>((resolve) =>
canvas.toBlob((blob) => resolve(blob as Blob), 'image/jpeg', settings.quality)
);
const jpegBytes = await (jpegBlob as Blob).arrayBuffer();
const jpegBytes = await jpegBlob.arrayBuffer();
const jpegImage = await newPdfDoc.embedJpg(jpegBytes);
const newPage = newPdfDoc.addPage([viewport.width, viewport.height]);
newPage.drawImage(jpegImage, {
@@ -219,13 +146,19 @@ async function performLegacyCompression(arrayBuffer: any, settings: any) {
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const fileDisplayArea = document.getElementById('file-display-area');
const compressOptions = document.getElementById('compress-options');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const processBtn = document.getElementById('process-btn');
const backBtn = document.getElementById('back-to-tools');
const algorithmSelect = document.getElementById('compression-algorithm') as HTMLSelectElement;
const condenseInfo = document.getElementById('condense-info');
const photonInfo = document.getElementById('photon-info');
const toggleCustomSettings = document.getElementById('toggle-custom-settings');
const customSettingsPanel = document.getElementById('custom-settings-panel');
const customSettingsChevron = document.getElementById('custom-settings-chevron');
let useCustomSettings = false;
if (backBtn) {
backBtn.addEventListener('click', () => {
@@ -233,60 +166,79 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// Toggle algorithm info
if (algorithmSelect && condenseInfo && photonInfo) {
algorithmSelect.addEventListener('change', () => {
if (algorithmSelect.value === 'condense') {
condenseInfo.classList.remove('hidden');
photonInfo.classList.add('hidden');
} else {
condenseInfo.classList.add('hidden');
photonInfo.classList.remove('hidden');
}
});
}
// Toggle custom settings panel
if (toggleCustomSettings && customSettingsPanel && customSettingsChevron) {
toggleCustomSettings.addEventListener('click', () => {
customSettingsPanel.classList.toggle('hidden');
customSettingsChevron.style.transform = customSettingsPanel.classList.contains('hidden')
? 'rotate(0deg)'
: 'rotate(180deg)';
// Mark that user wants to use custom settings
if (!customSettingsPanel.classList.contains('hidden')) {
useCustomSettings = true;
}
});
}
const updateUI = async () => {
if (!fileDisplayArea || !compressOptions || !processBtn || !fileControls) return;
if (!compressOptions) return;
if (state.files.length > 0) {
fileDisplayArea.innerHTML = '';
const fileDisplayArea = document.getElementById('file-display-area');
if (fileDisplayArea) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
metaSpan.textContent = `${formatBytes(file.size)}${pdfDoc.numPages} pages`;
} catch (error) {
console.error('Error loading PDF:', error);
metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`;
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
}
createIcons({ icons });
fileControls.classList.remove('hidden');
createIcons({ icons });
}
compressOptions.classList.remove('hidden');
(processBtn as HTMLButtonElement).disabled = false;
} else {
fileDisplayArea.innerHTML = '';
fileControls.classList.add('hidden');
compressOptions.classList.add('hidden');
(processBtn as HTMLButtonElement).disabled = true;
// Clear file display area
const fileDisplayArea = document.getElementById('file-display-area');
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
}
};
@@ -297,8 +249,30 @@ document.addEventListener('DOMContentLoaded', () => {
const compressionLevel = document.getElementById('compression-level') as HTMLSelectElement;
if (compressionLevel) compressionLevel.value = 'balanced';
const compressionAlgorithm = document.getElementById('compression-algorithm') as HTMLSelectElement;
if (compressionAlgorithm) compressionAlgorithm.value = 'vector';
if (algorithmSelect) algorithmSelect.value = 'condense';
useCustomSettings = false;
if (customSettingsPanel) customSettingsPanel.classList.add('hidden');
if (customSettingsChevron) customSettingsChevron.style.transform = 'rotate(0deg)';
const imageQuality = document.getElementById('image-quality') as HTMLInputElement;
const dpiTarget = document.getElementById('dpi-target') as HTMLInputElement;
const dpiThreshold = document.getElementById('dpi-threshold') as HTMLInputElement;
const removeMetadata = document.getElementById('remove-metadata') as HTMLInputElement;
const subsetFonts = document.getElementById('subset-fonts') as HTMLInputElement;
const convertToGrayscale = document.getElementById('convert-to-grayscale') as HTMLInputElement;
const removeThumbnails = document.getElementById('remove-thumbnails') as HTMLInputElement;
if (imageQuality) imageQuality.value = '75';
if (dpiTarget) dpiTarget.value = '96';
if (dpiThreshold) dpiThreshold.value = '150';
if (removeMetadata) removeMetadata.checked = true;
if (subsetFonts) subsetFonts.checked = true;
if (convertToGrayscale) convertToGrayscale.checked = false;
if (removeThumbnails) removeThumbnails.checked = true;
if (condenseInfo) condenseInfo.classList.remove('hidden');
if (photonInfo) photonInfo.classList.add('hidden');
updateUI();
};
@@ -306,52 +280,38 @@ document.addEventListener('DOMContentLoaded', () => {
const compress = async () => {
const level = (document.getElementById('compression-level') as HTMLSelectElement).value;
const algorithm = (document.getElementById('compression-algorithm') as HTMLSelectElement).value;
const convertToGrayscale = (document.getElementById('convert-to-grayscale') as HTMLInputElement)?.checked ?? false;
const settings = {
balanced: {
smart: {
quality: 0.5,
threshold: 0.95,
maxWidth: 1800,
maxHeight: 1800,
skipSize: 3000,
},
legacy: { scale: 1.5, quality: 0.6 },
},
'high-quality': {
smart: {
quality: 0.7,
threshold: 0.98,
maxWidth: 2500,
maxHeight: 2500,
skipSize: 5000,
},
legacy: { scale: 2.0, quality: 0.9 },
},
'small-size': {
smart: {
quality: 0.3,
threshold: 0.95,
maxWidth: 1200,
maxHeight: 1200,
skipSize: 2000,
},
legacy: { scale: 1.2, quality: 0.4 },
},
extreme: {
smart: {
quality: 0.1,
threshold: 0.95,
maxWidth: 1000,
maxHeight: 1000,
skipSize: 1000,
},
legacy: { scale: 1.0, quality: 0.2 },
},
};
let customSettings: {
imageQuality?: number;
dpiTarget?: number;
dpiThreshold?: number;
removeMetadata?: boolean;
subsetFonts?: boolean;
convertToGrayscale?: boolean;
removeThumbnails?: boolean;
} | undefined;
const smartSettings = { ...settings[level].smart, removeMetadata: true };
const legacySettings = settings[level].legacy;
if (useCustomSettings) {
const imageQuality = parseInt((document.getElementById('image-quality') as HTMLInputElement)?.value) || 75;
const dpiTarget = parseInt((document.getElementById('dpi-target') as HTMLInputElement)?.value) || 96;
const dpiThreshold = parseInt((document.getElementById('dpi-threshold') as HTMLInputElement)?.value) || 150;
const removeMetadata = (document.getElementById('remove-metadata') as HTMLInputElement)?.checked ?? true;
const subsetFonts = (document.getElementById('subset-fonts') as HTMLInputElement)?.checked ?? true;
const removeThumbnails = (document.getElementById('remove-thumbnails') as HTMLInputElement)?.checked ?? true;
customSettings = {
imageQuality,
dpiTarget,
dpiThreshold,
removeMetadata,
subsetFonts,
convertToGrayscale,
removeThumbnails,
};
} else {
customSettings = convertToGrayscale ? { convertToGrayscale } : undefined;
}
try {
if (state.files.length === 0) {
@@ -362,49 +322,40 @@ document.addEventListener('DOMContentLoaded', () => {
if (state.files.length === 1) {
const originalFile = state.files[0];
const arrayBuffer = await readFileAsArrayBuffer(originalFile);
let resultBytes;
let usedMethod;
let resultBlob: Blob;
let resultSize: number;
let usedMethod: string;
if (algorithm === 'vector') {
showLoader('Running Vector (Smart) compression...');
resultBytes = await performSmartCompression(arrayBuffer, smartSettings);
usedMethod = 'Vector';
} else if (algorithm === 'photon') {
showLoader('Running Photon (Rasterize) compression...');
resultBytes = await performLegacyCompression(arrayBuffer, legacySettings);
usedMethod = 'Photon';
} else {
showLoader('Running Automatic (Vector first)...');
const vectorResultBytes = await performSmartCompression(
arrayBuffer,
smartSettings
);
if (algorithm === 'condense') {
showLoader('Loading engine...');
const result = await performCondenseCompression(originalFile, level, customSettings);
resultBlob = result.blob;
resultSize = result.compressedSize;
usedMethod = 'Condense';
if (vectorResultBytes.length < originalFile.size) {
resultBytes = vectorResultBytes;
usedMethod = 'Vector (Automatic)';
} else {
showAlert('Vector failed to reduce size. Trying Photon...', 'info');
showLoader('Running Automatic (Photon fallback)...');
resultBytes = await performLegacyCompression(
arrayBuffer,
legacySettings
);
usedMethod = 'Photon (Automatic)';
// Check if fallback was used
if ((result as any).usedFallback) {
usedMethod += ' (without image optimization due to unsupported patterns)';
}
} else {
showLoader('Running Photon compression...');
const arrayBuffer = await readFileAsArrayBuffer(originalFile) as ArrayBuffer;
const resultBytes = await performPhotonCompression(arrayBuffer, level);
const buffer = resultBytes.buffer.slice(resultBytes.byteOffset, resultBytes.byteOffset + resultBytes.byteLength) as ArrayBuffer;
resultBlob = new Blob([buffer], { type: 'application/pdf' });
resultSize = resultBytes.length;
usedMethod = 'Photon';
}
const originalSize = formatBytes(originalFile.size);
const compressedSize = formatBytes(resultBytes.length);
const savings = originalFile.size - resultBytes.length;
const savingsPercent =
savings > 0 ? ((savings / originalFile.size) * 100).toFixed(1) : 0;
const compressedSize = formatBytes(resultSize);
const savings = originalFile.size - resultSize;
const savingsPercent = savings > 0 ? ((savings / originalFile.size) * 100).toFixed(1) : 0;
downloadFile(
new Blob([resultBytes], { type: 'application/pdf' }),
'compressed-final.pdf'
resultBlob,
originalFile.name.replace(/\.pdf$/i, '') + '_compressed.pdf'
);
hideLoader();
@@ -419,7 +370,7 @@ document.addEventListener('DOMContentLoaded', () => {
} else {
showAlert(
'Compression Finished',
`Method: ${usedMethod}. Could not reduce file size. Original: ${originalSize}, New: ${compressedSize}.`,
`Method: ${usedMethod}. Could not reduce file size further. Original: ${originalSize}, New: ${compressedSize}.`,
'warning',
() => resetState()
);
@@ -434,22 +385,15 @@ document.addEventListener('DOMContentLoaded', () => {
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Compressing ${i + 1}/${state.files.length}: ${file.name}...`);
const arrayBuffer = await readFileAsArrayBuffer(file);
totalOriginalSize += file.size;
let resultBytes;
if (algorithm === 'vector') {
resultBytes = await performSmartCompression(arrayBuffer, smartSettings);
} else if (algorithm === 'photon') {
resultBytes = await performLegacyCompression(arrayBuffer, legacySettings);
let resultBytes: Uint8Array;
if (algorithm === 'condense') {
const result = await performCondenseCompression(file, level, customSettings);
resultBytes = new Uint8Array(await result.blob.arrayBuffer());
} else {
const vectorResultBytes = await performSmartCompression(
arrayBuffer,
smartSettings
);
resultBytes = vectorResultBytes.length < file.size
? vectorResultBytes
: await performLegacyCompression(arrayBuffer, legacySettings);
const arrayBuffer = await readFileAsArrayBuffer(file) as ArrayBuffer;
resultBytes = await performPhotonCompression(arrayBuffer, level);
}
totalCompressedSize += resultBytes.length;
@@ -459,10 +403,9 @@ document.addEventListener('DOMContentLoaded', () => {
const zipBlob = await zip.generateAsync({ type: 'blob' });
const totalSavings = totalOriginalSize - totalCompressedSize;
const totalSavingsPercent =
totalSavings > 0
? ((totalSavings / totalOriginalSize) * 100).toFixed(1)
: 0;
const totalSavingsPercent = totalSavings > 0
? ((totalSavings / totalOriginalSize) * 100).toFixed(1)
: 0;
downloadFile(zipBlob, 'compressed-pdfs.zip');
@@ -486,6 +429,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
} catch (e: any) {
hideLoader();
console.error('[CompressPDF] Error:', e);
showAlert(
'Error',
`An error occurred during compression. Error: ${e.message}`
@@ -520,7 +464,7 @@ document.addEventListener('DOMContentLoaded', () => {
dropZone.classList.remove('bg-gray-700');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const pdfFiles = Array.from(files).filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
const pdfFiles = Array.from(files).filter(f => f.type === 'application/pdf');
if (pdfFiles.length > 0) {
const dataTransfer = new DataTransfer();
pdfFiles.forEach(f => dataTransfer.items.add(f));
@@ -529,7 +473,6 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// Clear value on click to allow re-selecting the same file
fileInput.addEventListener('click', () => {
fileInput.value = '';
});

View File

@@ -4,18 +4,10 @@ import { downloadFile, readFileAsArrayBuffer, formatBytes, getPDFDocument } from
import Cropper from 'cropperjs';
import * as pdfjsLib from 'pdfjs-dist';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import { CropperState } from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
interface CropperState {
pdfDoc: any;
currentPageNum: number;
cropper: any;
originalPdfBytes: ArrayBuffer | null;
pageCrops: Record<number, any>;
file: File | null;
}
const cropperState: CropperState = {
pdfDoc: null,
currentPageNum: 1,

View File

@@ -0,0 +1,231 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import {
downloadFile,
readFileAsArrayBuffer,
formatBytes,
} from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
document.addEventListener('DOMContentLoaded', () => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const convertOptions = document.getElementById('convert-options');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const processBtn = document.getElementById('process-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!convertOptions) return;
if (state.files.length > 0) {
if (fileDisplayArea) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
}
if (fileControls) fileControls.classList.remove('hidden');
convertOptions.classList.remove('hidden');
} else {
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
if (fileControls) fileControls.classList.add('hidden');
convertOptions.classList.add('hidden');
}
};
const resetState = () => {
state.files = [];
state.pdfDoc = null;
updateUI();
};
const convertToPdf = async () => {
try {
console.log('[CSV2PDF] Starting conversion...');
console.log('[CSV2PDF] Number of files:', state.files.length);
if (state.files.length === 0) {
showAlert('No Files', 'Please select at least one CSV file.');
hideLoader();
return;
}
const { convertCsvToPdf } = await import('../utils/csv-to-pdf.js');
if (state.files.length === 1) {
const originalFile = state.files[0];
console.log('[CSV2PDF] Converting single file:', originalFile.name, 'Size:', originalFile.size, 'bytes');
const pdfBlob = await convertCsvToPdf(originalFile, {
onProgress: (percent, message) => {
console.log(`[CSV2PDF] Progress: ${percent}% - ${message}`);
showLoader(message, percent);
}
});
console.log('[CSV2PDF] Conversion complete! PDF size:', pdfBlob.size, 'bytes');
const fileName = originalFile.name.replace(/\.csv$/i, '') + '.pdf';
downloadFile(pdfBlob, fileName);
console.log('[CSV2PDF] File downloaded:', fileName);
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${originalFile.name} to PDF.`,
'success',
() => resetState()
);
} else {
console.log('[CSV2PDF] Converting multiple files:', state.files.length);
showLoader('Preparing conversion...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
console.log(`[CSV2PDF] Converting file ${i + 1}/${state.files.length}:`, file.name);
const pdfBlob = await convertCsvToPdf(file, {
onProgress: (percent, message) => {
const overallPercent = ((i / state.files.length) * 100) + (percent / state.files.length);
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`, overallPercent);
}
});
console.log(`[CSV2PDF] Converted ${file.name}, PDF size:`, pdfBlob.size);
const baseName = file.name.replace(/\.csv$/i, '');
const pdfBuffer = await pdfBlob.arrayBuffer();
zip.file(`${baseName}.pdf`, pdfBuffer);
}
console.log('[CSV2PDF] Generating ZIP file...');
showLoader('Creating ZIP archive...');
const zipBlob = await zip.generateAsync({ type: 'blob' });
console.log('[CSV2PDF] ZIP size:', zipBlob.size);
downloadFile(zipBlob, 'csv-converted.zip');
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} CSV file(s) to PDF.`,
'success',
() => resetState()
);
}
} catch (e: any) {
console.error('[CSV2PDF] ERROR:', e);
console.error('[CSV2PDF] Error stack:', e.stack);
hideLoader();
showAlert(
'Error',
`An error occurred during conversion. Error: ${e.message}`
);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
state.files = [...state.files, ...Array.from(files)];
updateUI();
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const csvFiles = Array.from(files).filter(f => f.name.toLowerCase().endsWith('.csv') || f.type === 'text/csv');
if (csvFiles.length > 0) {
const dataTransfer = new DataTransfer();
csvFiles.forEach(f => dataTransfer.items.add(f));
handleFileSelect(dataTransfer.files);
}
}
});
// Clear value on click to allow re-selecting the same file
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput.click();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', () => {
resetState();
});
}
if (processBtn) {
processBtn.addEventListener('click', convertToPdf);
}
updateUI();
});

View File

@@ -1,12 +1,9 @@
import { showAlert } from '../ui.js';
import { downloadFile, formatBytes, initializeQpdf, readFileAsArrayBuffer } from '../utils/helpers.js';
import { icons, createIcons } from 'lucide';
import { DecryptPdfState } from '@/types';
interface PageState {
file: File | null;
}
const pageState: PageState = {
const pageState: DecryptPdfState = {
file: null,
};

View File

@@ -3,18 +3,11 @@ import { showAlert, showLoader, hideLoader } from '../ui.js';
import { readFileAsArrayBuffer, formatBytes, downloadFile, getPDFDocument, parsePageRanges } from '../utils/helpers.js';
import { PDFDocument } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
import { DeletePagesState } from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
interface DeleteState {
file: File | null;
pdfDoc: any;
pdfJsDoc: any;
totalPages: number;
pagesToDelete: Set<number>;
}
const deleteState: DeleteState = {
const deleteState: DeletePagesState = {
file: null,
pdfDoc: null,
pdfJsDoc: null,

View File

@@ -0,0 +1,689 @@
import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { readFileAsArrayBuffer, formatBytes, downloadFile, getPDFDocument } from '../utils/helpers.js';
import {
signPdf,
parsePfxFile,
parseCombinedPem,
getCertificateInfo,
} from './digital-sign-pdf.js';
import { SignatureInfo, VisibleSignatureOptions, DigitalSignState } from '@/types';
const state: DigitalSignState = {
pdfFile: null,
pdfBytes: null,
certFile: null,
certData: null,
sigImageData: null,
sigImageType: null,
};
function resetState(): void {
state.pdfFile = null;
state.pdfBytes = null;
state.certFile = null;
state.certData = null;
state.sigImageData = null;
state.sigImageType = null;
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
if (certDisplayArea) certDisplayArea.innerHTML = '';
const fileInput = getElement<HTMLInputElement>('file-input');
if (fileInput) fileInput.value = '';
const certInput = getElement<HTMLInputElement>('cert-input');
if (certInput) certInput.value = '';
const sigImageInput = getElement<HTMLInputElement>('sig-image-input');
if (sigImageInput) sigImageInput.value = '';
const sigImagePreview = getElement<HTMLDivElement>('sig-image-preview');
if (sigImagePreview) sigImagePreview.classList.add('hidden');
const certSection = getElement<HTMLDivElement>('certificate-section');
if (certSection) certSection.classList.add('hidden');
hidePasswordSection();
hideSignatureOptions();
hideCertInfo();
updateProcessButton();
}
function getElement<T extends HTMLElement>(id: string): T | null {
return document.getElementById(id) as T | null;
}
function initializePage(): void {
createIcons({ icons });
const fileInput = getElement<HTMLInputElement>('file-input');
const dropZone = getElement<HTMLDivElement>('drop-zone');
const certInput = getElement<HTMLInputElement>('cert-input');
const certDropZone = getElement<HTMLDivElement>('cert-drop-zone');
const certPassword = getElement<HTMLInputElement>('cert-password');
const processBtn = getElement<HTMLButtonElement>('process-btn');
const backBtn = getElement<HTMLButtonElement>('back-to-tools');
if (fileInput) {
fileInput.addEventListener('change', handlePdfUpload);
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (dropZone) {
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
handlePdfFile(droppedFiles[0]);
}
});
}
if (certInput) {
certInput.addEventListener('change', handleCertUpload);
certInput.addEventListener('click', () => {
certInput.value = '';
});
}
if (certDropZone) {
certDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
certDropZone.classList.add('bg-gray-700');
});
certDropZone.addEventListener('dragleave', () => {
certDropZone.classList.remove('bg-gray-700');
});
certDropZone.addEventListener('drop', (e) => {
e.preventDefault();
certDropZone.classList.remove('bg-gray-700');
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
handleCertFile(droppedFiles[0]);
}
});
}
if (certPassword) {
certPassword.addEventListener('input', handlePasswordInput);
certPassword.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
handlePasswordInput();
}
});
}
if (processBtn) {
processBtn.addEventListener('click', processSignature);
}
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const enableVisibleSig = getElement<HTMLInputElement>('enable-visible-sig');
const visibleSigOptions = getElement<HTMLDivElement>('visible-sig-options');
const sigPage = getElement<HTMLSelectElement>('sig-page');
const customPageWrapper = getElement<HTMLDivElement>('custom-page-wrapper');
const sigImageInput = getElement<HTMLInputElement>('sig-image-input');
const sigImagePreview = getElement<HTMLDivElement>('sig-image-preview');
const sigImageThumb = getElement<HTMLImageElement>('sig-image-thumb');
const removeSigImage = getElement<HTMLButtonElement>('remove-sig-image');
const enableSigText = getElement<HTMLInputElement>('enable-sig-text');
const sigTextOptions = getElement<HTMLDivElement>('sig-text-options');
if (enableVisibleSig && visibleSigOptions) {
enableVisibleSig.addEventListener('change', () => {
if (enableVisibleSig.checked) {
visibleSigOptions.classList.remove('hidden');
} else {
visibleSigOptions.classList.add('hidden');
}
});
}
if (sigPage && customPageWrapper) {
sigPage.addEventListener('change', () => {
if (sigPage.value === 'custom') {
customPageWrapper.classList.remove('hidden');
} else {
customPageWrapper.classList.add('hidden');
}
});
}
if (sigImageInput) {
sigImageInput.addEventListener('change', async (e) => {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
const file = input.files[0];
const validTypes = ['image/png', 'image/jpeg', 'image/webp'];
if (!validTypes.includes(file.type)) {
showAlert('Invalid Image', 'Please select a PNG, JPG, or WebP image.');
return;
}
state.sigImageData = await readFileAsArrayBuffer(file) as ArrayBuffer;
state.sigImageType = file.type.replace('image/', '') as 'png' | 'jpeg' | 'webp';
if (sigImageThumb && sigImagePreview) {
const url = URL.createObjectURL(file);
sigImageThumb.src = url;
sigImagePreview.classList.remove('hidden');
}
}
});
}
if (removeSigImage && sigImagePreview) {
removeSigImage.addEventListener('click', () => {
state.sigImageData = null;
state.sigImageType = null;
sigImagePreview.classList.add('hidden');
if (sigImageInput) sigImageInput.value = '';
});
}
if (enableSigText && sigTextOptions) {
enableSigText.addEventListener('change', () => {
if (enableSigText.checked) {
sigTextOptions.classList.remove('hidden');
} else {
sigTextOptions.classList.add('hidden');
}
});
}
}
function handlePdfUpload(e: Event): void {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handlePdfFile(input.files[0]);
}
}
async function handlePdfFile(file: File): Promise<void> {
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
showAlert('Invalid File', 'Please select a PDF file.');
return;
}
state.pdfFile = file;
state.pdfBytes = new Uint8Array(await readFileAsArrayBuffer(file) as ArrayBuffer);
updatePdfDisplay();
showCertificateSection();
}
async function updatePdfDisplay(): Promise<void> {
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
if (!fileDisplayArea || !state.pdfFile) return;
fileDisplayArea.innerHTML = '';
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = state.pdfFile.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(state.pdfFile.size)} • Loading pages...`;
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.pdfFile = null;
state.pdfBytes = null;
fileDisplayArea.innerHTML = '';
hideCertificateSection();
updateProcessButton();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
createIcons({ icons });
try {
if (state.pdfBytes) {
const pdfDoc = await getPDFDocument({ data: state.pdfBytes.slice() }).promise;
metaSpan.textContent = `${formatBytes(state.pdfFile.size)}${pdfDoc.numPages} pages`;
}
} catch (error) {
console.error('Error loading PDF:', error);
metaSpan.textContent = `${formatBytes(state.pdfFile.size)}`;
}
}
function showCertificateSection(): void {
const certSection = getElement<HTMLDivElement>('certificate-section');
if (certSection) {
certSection.classList.remove('hidden');
}
}
function hideCertificateSection(): void {
const certSection = getElement<HTMLDivElement>('certificate-section');
const signatureOptions = getElement<HTMLDivElement>('signature-options');
if (certSection) {
certSection.classList.add('hidden');
}
if (signatureOptions) {
signatureOptions.classList.add('hidden');
}
state.certFile = null;
state.certData = null;
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
if (certDisplayArea) {
certDisplayArea.innerHTML = '';
}
const certInfo = getElement<HTMLDivElement>('cert-info');
if (certInfo) {
certInfo.classList.add('hidden');
}
const certPasswordSection = getElement<HTMLDivElement>('cert-password-section');
if (certPasswordSection) {
certPasswordSection.classList.add('hidden');
}
}
function handleCertUpload(e: Event): void {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handleCertFile(input.files[0]);
}
}
async function handleCertFile(file: File): Promise<void> {
const validExtensions = ['.pfx', '.p12', '.pem'];
const hasValidExtension = validExtensions.some(ext =>
file.name.toLowerCase().endsWith(ext)
);
if (!hasValidExtension) {
showAlert('Invalid Certificate', 'Please select a .pfx, .p12, or .pem certificate file.');
return;
}
state.certFile = file;
state.certData = null;
updateCertDisplay();
const isPemFile = file.name.toLowerCase().endsWith('.pem');
if (isPemFile) {
try {
const pemContent = await file.text();
const isEncrypted = pemContent.includes('ENCRYPTED');
if (isEncrypted) {
showPasswordSection();
updatePasswordLabel('Private Key Password');
} else {
state.certData = parseCombinedPem(pemContent);
updateCertInfo();
showSignatureOptions();
const certStatus = getElement<HTMLDivElement>('cert-status');
if (certStatus) {
certStatus.innerHTML = 'Certificate loaded <i data-lucide="check" class="inline w-4 h-4"></i>';
createIcons({ icons });
certStatus.className = 'text-xs text-green-400';
}
}
} catch (error) {
const certStatus = getElement<HTMLDivElement>('cert-status');
if (certStatus) {
certStatus.textContent = 'Failed to parse PEM file';
certStatus.className = 'text-xs text-red-400';
}
}
} else {
showPasswordSection();
updatePasswordLabel('Certificate Password');
}
hideSignatureOptions();
updateProcessButton();
}
function updateCertDisplay(): void {
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
if (!certDisplayArea || !state.certFile) return;
certDisplayArea.innerHTML = '';
const certDiv = document.createElement('div');
certDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = state.certFile.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.id = 'cert-status';
metaSpan.textContent = 'Enter password to unlock';
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.certFile = null;
state.certData = null;
certDisplayArea.innerHTML = '';
hidePasswordSection();
hideCertInfo();
hideSignatureOptions();
updateProcessButton();
};
certDiv.append(infoContainer, removeBtn);
certDisplayArea.appendChild(certDiv);
createIcons({ icons });
}
function showPasswordSection(): void {
const certPasswordSection = getElement<HTMLDivElement>('cert-password-section');
if (certPasswordSection) {
certPasswordSection.classList.remove('hidden');
}
const certPassword = getElement<HTMLInputElement>('cert-password');
if (certPassword) {
certPassword.value = '';
certPassword.focus();
}
}
function updatePasswordLabel(labelText: string): void {
const label = document.querySelector('label[for="cert-password"]');
if (label) {
label.textContent = labelText;
}
}
function hidePasswordSection(): void {
const certPasswordSection = getElement<HTMLDivElement>('cert-password-section');
if (certPasswordSection) {
certPasswordSection.classList.add('hidden');
}
}
function showSignatureOptions(): void {
const signatureOptions = getElement<HTMLDivElement>('signature-options');
if (signatureOptions) {
signatureOptions.classList.remove('hidden');
}
const visibleSigSection = getElement<HTMLDivElement>('visible-signature-section');
if (visibleSigSection) {
visibleSigSection.classList.remove('hidden');
}
}
function hideSignatureOptions(): void {
const signatureOptions = getElement<HTMLDivElement>('signature-options');
if (signatureOptions) {
signatureOptions.classList.add('hidden');
}
const visibleSigSection = getElement<HTMLDivElement>('visible-signature-section');
if (visibleSigSection) {
visibleSigSection.classList.add('hidden');
}
}
function hideCertInfo(): void {
const certInfo = getElement<HTMLDivElement>('cert-info');
if (certInfo) {
certInfo.classList.add('hidden');
}
}
async function handlePasswordInput(): Promise<void> {
const certPassword = getElement<HTMLInputElement>('cert-password');
const password = certPassword?.value ?? '';
if (!state.certFile || !password) {
return;
}
try {
const isPemFile = state.certFile.name.toLowerCase().endsWith('.pem');
if (isPemFile) {
const pemContent = await state.certFile.text();
state.certData = parseCombinedPem(pemContent, password);
} else {
const certBytes = await readFileAsArrayBuffer(state.certFile) as ArrayBuffer;
state.certData = parsePfxFile(certBytes, password);
}
updateCertInfo();
showSignatureOptions();
updateProcessButton();
const certStatus = getElement<HTMLDivElement>('cert-status');
if (certStatus) {
certStatus.innerHTML = 'Certificate unlocked <i data-lucide="check-circle" class="inline w-4 h-4"></i>';
createIcons({ icons });
certStatus.className = 'text-xs text-green-400';
}
} catch (error) {
state.certData = null;
hideSignatureOptions();
updateProcessButton();
const certStatus = getElement<HTMLDivElement>('cert-status');
if (certStatus) {
const errorMessage = error instanceof Error ? error.message : 'Invalid password or certificate';
certStatus.textContent = errorMessage.includes('password')
? 'Incorrect password'
: 'Failed to parse certificate';
certStatus.className = 'text-xs text-red-400';
}
}
}
function updateCertInfo(): void {
if (!state.certData) return;
const certInfo = getElement<HTMLDivElement>('cert-info');
const certSubject = getElement<HTMLSpanElement>('cert-subject');
const certIssuer = getElement<HTMLSpanElement>('cert-issuer');
const certValidity = getElement<HTMLSpanElement>('cert-validity');
if (!certInfo) return;
const info = getCertificateInfo(state.certData.certificate);
if (certSubject) {
certSubject.textContent = info.subject;
}
if (certIssuer) {
certIssuer.textContent = info.issuer;
}
if (certValidity) {
const formatDate = (date: Date) => date.toLocaleDateString();
certValidity.textContent = `${formatDate(info.validFrom)} - ${formatDate(info.validTo)}`;
}
certInfo.classList.remove('hidden');
}
function updateProcessButton(): void {
const processBtn = getElement<HTMLButtonElement>('process-btn');
if (!processBtn) return;
const canProcess = state.pdfBytes !== null && state.certData !== null;
if (canProcess) {
processBtn.style.display = '';
} else {
processBtn.style.display = 'none';
}
}
async function processSignature(): Promise<void> {
if (!state.pdfBytes || !state.certData) {
showAlert('Missing Data', 'Please upload both a PDF and a valid certificate.');
return;
}
const reason = getElement<HTMLInputElement>('sign-reason')?.value ?? '';
const location = getElement<HTMLInputElement>('sign-location')?.value ?? '';
const contactInfo = getElement<HTMLInputElement>('sign-contact')?.value ?? '';
const signatureInfo: SignatureInfo = {};
if (reason) signatureInfo.reason = reason;
if (location) signatureInfo.location = location;
if (contactInfo) signatureInfo.contactInfo = contactInfo;
let visibleSignature: VisibleSignatureOptions | undefined;
const enableVisibleSig = getElement<HTMLInputElement>('enable-visible-sig');
if (enableVisibleSig?.checked) {
const sigX = parseInt(getElement<HTMLInputElement>('sig-x')?.value ?? '25', 10);
const sigY = parseInt(getElement<HTMLInputElement>('sig-y')?.value ?? '700', 10);
const sigWidth = parseInt(getElement<HTMLInputElement>('sig-width')?.value ?? '150', 10);
const sigHeight = parseInt(getElement<HTMLInputElement>('sig-height')?.value ?? '70', 10);
const sigPageSelect = getElement<HTMLSelectElement>('sig-page');
let sigPage: number | string = 0;
let numPages = 1;
try {
const pdfDoc = await getPDFDocument({ data: state.pdfBytes.slice() }).promise;
numPages = pdfDoc.numPages;
} catch (error) {
console.error('Error getting PDF page count:', error);
}
if (sigPageSelect) {
if (sigPageSelect.value === 'last') {
sigPage = (numPages - 1).toString();
} else if (sigPageSelect.value === 'all') {
if (numPages === 1) {
sigPage = '0';
} else {
sigPage = `0-${numPages - 1}`;
}
} else if (sigPageSelect.value === 'custom') {
sigPage = parseInt(getElement<HTMLInputElement>('sig-custom-page')?.value ?? '1', 10) - 1;
} else {
sigPage = parseInt(sigPageSelect.value, 10);
}
}
const enableSigText = getElement<HTMLInputElement>('enable-sig-text');
let sigText = enableSigText?.checked ? getElement<HTMLInputElement>('sig-text')?.value : undefined;
const sigTextColor = getElement<HTMLInputElement>('sig-text-color')?.value ?? '#000000';
const sigTextSize = parseInt(getElement<HTMLInputElement>('sig-text-size')?.value ?? '12', 10);
if (!state.sigImageData && !sigText && state.certData) {
const certInfo = getCertificateInfo(state.certData.certificate);
const date = new Date().toLocaleDateString();
sigText = `Digitally signed by ${certInfo.subject}\n${date}`;
}
let finalHeight = sigHeight;
if (sigText && !state.sigImageData) {
const lineCount = (sigText.match(/\n/g) || []).length + 1;
const lineHeightFactor = 1.4;
const padding = 16;
const calculatedHeight = Math.ceil(lineCount * sigTextSize * lineHeightFactor + padding);
finalHeight = Math.max(calculatedHeight, sigHeight);
}
visibleSignature = {
enabled: true,
x: sigX,
y: sigY,
width: sigWidth,
height: finalHeight,
page: sigPage,
imageData: state.sigImageData ?? undefined,
imageType: state.sigImageType ?? undefined,
text: sigText,
textColor: sigTextColor,
textSize: sigTextSize,
};
}
showLoader('Applying digital signature...');
try {
const signedPdfBytes = await signPdf(state.pdfBytes, state.certData, {
signatureInfo,
visibleSignature,
});
const blob = new Blob([signedPdfBytes.slice().buffer], { type: 'application/pdf' });
const originalName = state.pdfFile?.name ?? 'document.pdf';
const signedName = originalName.replace(/\.pdf$/i, '_signed.pdf');
downloadFile(blob, signedName);
hideLoader();
showAlert('Success', 'PDF signed successfully! The signature can be verified in any PDF reader.', 'success', () => { resetState(); });
} catch (error) {
hideLoader();
console.error('Signing error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
// Check if this is a CORS/network error from certificate chain fetching
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('CORS') || errorMessage.includes('NetworkError')) {
showAlert(
'Signing Failed',
'Failed to fetch certificate chain. This may be due to network issues or the certificate proxy being unavailable. Please check your internet connection and try again. If the issue persists, contact support.'
);
} else {
showAlert('Signing Failed', `Failed to sign PDF: ${errorMessage}`);
}
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}

View File

@@ -0,0 +1,283 @@
import { PdfSigner, type SignOption } from 'zgapdfsigner';
import forge from 'node-forge';
import { CertificateData, SignPdfOptions } from '@/types';
export function parsePfxFile(pfxBytes: ArrayBuffer, password: string): CertificateData {
const pfxAsn1 = forge.asn1.fromDer(forge.util.createBuffer(new Uint8Array(pfxBytes)));
const pfx = forge.pkcs12.pkcs12FromAsn1(pfxAsn1, password);
const certBags = pfx.getBags({ bagType: forge.pki.oids.certBag });
const keyBags = pfx.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
const certBagArray = certBags[forge.pki.oids.certBag];
const keyBagArray = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag];
if (!certBagArray || certBagArray.length === 0) {
throw new Error('No certificate found in PFX file');
}
if (!keyBagArray || keyBagArray.length === 0) {
throw new Error('No private key found in PFX file');
}
const certificate = certBagArray[0].cert;
if (!certificate) {
throw new Error('Failed to extract certificate from PFX file');
}
return { p12Buffer: pfxBytes, password, certificate };
}
export function parsePemFiles(
certPem: string,
keyPem: string,
keyPassword?: string
): CertificateData {
const certificate = forge.pki.certificateFromPem(certPem);
let privateKey: forge.pki.PrivateKey;
if (keyPem.includes('ENCRYPTED')) {
if (!keyPassword) {
throw new Error('Password required for encrypted private key');
}
privateKey = forge.pki.decryptRsaPrivateKey(keyPem, keyPassword);
if (!privateKey) {
throw new Error('Failed to decrypt private key');
}
} else {
privateKey = forge.pki.privateKeyFromPem(keyPem);
}
const p12Password = keyPassword || 'temp-password';
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(
privateKey,
[certificate],
p12Password,
{ algorithm: '3des' }
);
const p12Der = forge.asn1.toDer(p12Asn1).getBytes();
const p12Buffer = new Uint8Array(p12Der.length);
for (let i = 0; i < p12Der.length; i++) {
p12Buffer[i] = p12Der.charCodeAt(i);
}
return { p12Buffer: p12Buffer.buffer, password: p12Password, certificate };
}
export function parseCombinedPem(pemContent: string, password?: string): CertificateData {
const certMatch = pemContent.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/);
const keyMatch = pemContent.match(/-----BEGIN (RSA |EC |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (RSA |EC |ENCRYPTED )?PRIVATE KEY-----/);
if (!certMatch) {
throw new Error('No certificate found in PEM file');
}
if (!keyMatch) {
throw new Error('No private key found in PEM file');
}
return parsePemFiles(certMatch[0], keyMatch[0], password);
}
/**
* CORS Proxy URL for fetching external certificates.
* The zgapdfsigner library tries to fetch issuer certificates from external URLs,
* but those servers often don't have CORS headers. This proxy adds the necessary
* CORS headers to allow the requests from the browser.
*
* If you are self-hosting, you MUST deploy your own proxy using cloudflare/cors-proxy-worker.js or any other way of your choice
* and set VITE_CORS_PROXY_URL environment variable.
*
* If not set, certificates requiring external chain fetching will fail.
*/
const CORS_PROXY_URL = import.meta.env.VITE_CORS_PROXY_URL || '';
/**
* Shared secret for signing proxy requests (HMAC-SHA256).
*
* SECURITY NOTE FOR PRODUCTION:
* Client-side secrets are NEVER truly hidden and they can be extracted from
* bundled JavaScript.
*
* For production deployments with sensitive requirements, you should:
* 1. Use your own backend server to proxy certificate requests
* 2. Keep the HMAC secret on your server ONLY (never in frontend code)
* 3. Have your frontend call your server, which then calls the CORS proxy
*
* This client-side HMAC provides limited protection (deters casual abuse)
* but should NOT be considered secure against determined attackers. BentoPDF
* accepts this tradeoff because of it's client side architecture.
*
* To enable (optional):
* 1. Generate a secret: openssl rand -hex 32
* 2. Set PROXY_SECRET on your Cloudflare Worker: npx wrangler secret put PROXY_SECRET
* 3. Set VITE_CORS_PROXY_SECRET in your build environment (must match PROXY_SECRET)
*/
const CORS_PROXY_SECRET = import.meta.env.VITE_CORS_PROXY_SECRET || '';
async function generateProxySignature(url: string, timestamp: number): Promise<string> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(CORS_PROXY_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const message = `${url}${timestamp}`;
const signature = await crypto.subtle.sign(
'HMAC',
key,
encoder.encode(message)
);
return Array.from(new Uint8Array(signature))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Custom fetch wrapper that routes external certificate requests through a CORS proxy.
* The zgapdfsigner library tries to fetch issuer certificates from URLs embedded in the
* certificate's AIA extension. When those servers don't have CORS enabled (like www.cert.fnmt.es),
* the fetch fails. This wrapper routes such requests through our CORS proxy.
*
* If VITE_CORS_PROXY_SECRET is configured, requests include HMAC signatures for anti-spoofing.
*/
function createCorsAwareFetch(): {
wrappedFetch: typeof fetch;
restore: () => void;
} {
const originalFetch = window.fetch.bind(window);
const wrappedFetch: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
const isExternalCertificateUrl = (
url.includes('.crt') ||
url.includes('.cer') ||
url.includes('.pem') ||
url.includes('/certs/') ||
url.includes('/ocsp') ||
url.includes('/crl') ||
url.includes('caIssuers')
) && !url.startsWith(window.location.origin);
if (isExternalCertificateUrl && CORS_PROXY_URL) {
let proxyUrl = `${CORS_PROXY_URL}?url=${encodeURIComponent(url)}`;
if (CORS_PROXY_SECRET) {
const timestamp = Date.now();
const signature = await generateProxySignature(url, timestamp);
proxyUrl += `&t=${timestamp}&sig=${signature}`;
console.log(`[CORS Proxy] Routing signed certificate request through proxy: ${url}`);
} else {
console.log(`[CORS Proxy] Routing certificate request through proxy: ${url}`);
}
return originalFetch(proxyUrl, init);
}
return originalFetch(input, init);
};
window.fetch = wrappedFetch;
return {
wrappedFetch,
restore: () => {
window.fetch = originalFetch;
}
};
}
export async function signPdf(
pdfBytes: Uint8Array,
certificateData: CertificateData,
options: SignPdfOptions = {}
): Promise<Uint8Array> {
const signatureInfo = options.signatureInfo ?? {};
const signOptions: SignOption = {
p12cert: certificateData.p12Buffer,
pwd: certificateData.password,
};
if (signatureInfo.reason) {
signOptions.reason = signatureInfo.reason;
}
if (signatureInfo.location) {
signOptions.location = signatureInfo.location;
}
if (signatureInfo.contactInfo) {
signOptions.contact = signatureInfo.contactInfo;
}
if (options.visibleSignature?.enabled) {
const vs = options.visibleSignature;
const drawinf = {
area: {
x: vs.x,
y: vs.y,
w: vs.width,
h: vs.height,
},
pageidx: vs.page,
imgInfo: undefined as { imgData: ArrayBuffer; imgType: string } | undefined,
textInfo: undefined as { text: string; size: number; color: string } | undefined,
};
if (vs.imageData && vs.imageType) {
drawinf.imgInfo = {
imgData: vs.imageData,
imgType: vs.imageType,
};
}
if (vs.text) {
drawinf.textInfo = {
text: vs.text,
size: vs.textSize ?? 12,
color: vs.textColor ?? '#000000',
};
}
signOptions.drawinf = drawinf as SignOption['drawinf'];
}
const signer = new PdfSigner(signOptions);
const { restore } = createCorsAwareFetch();
try {
const signedPdfBytes = await signer.sign(pdfBytes);
return new Uint8Array(signedPdfBytes);
} finally {
restore();
}
}
export function getCertificateInfo(certificate: forge.pki.Certificate): {
subject: string;
issuer: string;
validFrom: Date;
validTo: Date;
serialNumber: string;
} {
const subjectCN = certificate.subject.getField('CN');
const issuerCN = certificate.issuer.getField('CN');
return {
subject: subjectCN?.value as string ?? 'Unknown',
issuer: issuerCN?.value as string ?? 'Unknown',
validFrom: certificate.validity.notBefore,
validTo: certificate.validity.notAfter,
serialNumber: certificate.serialNumber,
};
}

View File

@@ -1,21 +1,19 @@
import { DividePagesState } from '@/types';
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { downloadFile, formatBytes, parsePageRanges } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
interface DividePagesState {
file: File | null;
pdfDoc: PDFLibDocument | null;
}
const pageState: DividePagesState = {
file: null,
pdfDoc: null,
totalPages: 0,
};
function resetState() {
pageState.file = null;
pageState.pdfDoc = null;
pageState.totalPages = 0;
const fileDisplayArea = document.getElementById('file-display-area');
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
@@ -28,6 +26,9 @@ function resetState() {
const splitTypeSelect = document.getElementById('split-type') as HTMLSelectElement;
if (splitTypeSelect) splitTypeSelect.value = 'vertical';
const pageRangeInput = document.getElementById('page-range') as HTMLInputElement;
if (pageRangeInput) pageRangeInput.value = '';
}
async function updateUI() {
@@ -73,10 +74,10 @@ async function updateUI() {
ignoreEncryption: true,
throwOnInvalidObject: false
});
pageState.totalPages = pageState.pdfDoc.getPageCount();
hideLoader();
const pageCount = pageState.pdfDoc.getPageCount();
metaSpan.textContent = `${formatBytes(pageState.file.size)}${pageCount} pages`;
metaSpan.textContent = `${formatBytes(pageState.file.size)}${pageState.totalPages} pages`;
if (toolOptions) toolOptions.classList.remove('hidden');
} catch (error) {
@@ -96,9 +97,25 @@ async function dividePages() {
return;
}
const pageRangeInput = document.getElementById('page-range') as HTMLInputElement;
const pageRangeValue = pageRangeInput?.value.trim().toLowerCase() || '';
const splitTypeSelect = document.getElementById('split-type') as HTMLSelectElement;
const splitType = splitTypeSelect.value;
let pagesToDivide: Set<number>;
if (pageRangeValue === '' || pageRangeValue === 'all') {
pagesToDivide = new Set(Array.from({ length: pageState.totalPages }, (_, i) => i + 1));
} else {
const parsedIndices = parsePageRanges(pageRangeValue, pageState.totalPages);
pagesToDivide = new Set(parsedIndices.map(i => i + 1));
if (pagesToDivide.size === 0) {
showAlert('Invalid Range', 'Please enter a valid page range (e.g., 1-5, 8, 11-13).');
return;
}
}
showLoader('Splitting PDF pages...');
try {
@@ -106,27 +123,33 @@ async function dividePages() {
const pages = pageState.pdfDoc.getPages();
for (let i = 0; i < pages.length; i++) {
const pageNum = i + 1;
const originalPage = pages[i];
const { width, height } = originalPage.getSize();
showLoader(`Processing page ${i + 1} of ${pages.length}...`);
showLoader(`Processing page ${pageNum} of ${pages.length}...`);
const [page1] = await newPdfDoc.copyPages(pageState.pdfDoc, [i]);
const [page2] = await newPdfDoc.copyPages(pageState.pdfDoc, [i]);
if (pagesToDivide.has(pageNum)) {
const [page1] = await newPdfDoc.copyPages(pageState.pdfDoc, [i]);
const [page2] = await newPdfDoc.copyPages(pageState.pdfDoc, [i]);
switch (splitType) {
case 'vertical':
page1.setCropBox(0, 0, width / 2, height);
page2.setCropBox(width / 2, 0, width / 2, height);
break;
case 'horizontal':
page1.setCropBox(0, height / 2, width, height / 2);
page2.setCropBox(0, 0, width, height / 2);
break;
switch (splitType) {
case 'vertical':
page1.setCropBox(0, 0, width / 2, height);
page2.setCropBox(width / 2, 0, width / 2, height);
break;
case 'horizontal':
page1.setCropBox(0, height / 2, width, height / 2);
page2.setCropBox(0, 0, width, height / 2);
break;
}
newPdfDoc.addPage(page1);
newPdfDoc.addPage(page2);
} else {
const [copiedPage] = await newPdfDoc.copyPages(pageState.pdfDoc, [i]);
newPdfDoc.addPage(copiedPage);
}
newPdfDoc.addPage(page1);
newPdfDoc.addPage(page2);
}
const newPdfBytes = await newPdfDoc.save();

View File

@@ -1,22 +1,10 @@
import { EditAttachmentState, AttachmentInfo } from '@/types';
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
const worker = new Worker(import.meta.env.BASE_URL + 'workers/edit-attachments.worker.js');
interface AttachmentInfo {
index: number;
name: string;
page: number;
data: Uint8Array;
}
interface EditAttachmentState {
file: File | null;
allAttachments: AttachmentInfo[];
attachmentsToRemove: Set<number>;
}
const pageState: EditAttachmentState = {
file: null,
allAttachments: [],

View File

@@ -1,13 +1,9 @@
import { EditMetadataState } from '@/types';
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
import { PDFDocument as PDFLibDocument, PDFName, PDFString } from 'pdf-lib';
interface EditMetadataState {
file: File | null;
pdfDoc: PDFLibDocument | null;
}
const pageState: EditMetadataState = {
file: null,
pdfDoc: null,

View File

@@ -4,8 +4,8 @@ import { showAlert, showLoader, hideLoader } from '../ui.js';
import { formatBytes } from '../utils/helpers.js';
const embedPdfWasmUrl = new URL(
'embedpdf-snippet/dist/pdfium.wasm',
import.meta.url
'embedpdf-snippet/dist/pdfium.wasm',
import.meta.url
).href;
let currentPdfUrl: string | null = null;
@@ -45,7 +45,6 @@ function initializePage() {
}
});
// Clear value on click to allow re-selecting the same file
fileInput?.addEventListener('click', () => {
if (fileInput) fileInput.value = '';
});
@@ -81,11 +80,7 @@ async function handleFiles(files: FileList) {
if (!pdfWrapper || !pdfContainer || !uploader || !dropZone || !fileDisplayArea) return;
// Hide uploader elements but keep the container
// Hide uploader elements but keep the container
// dropZone.classList.add('hidden');
// Show file display
fileDisplayArea.innerHTML = '';
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
@@ -114,7 +109,6 @@ async function handleFiles(files: FileList) {
pdfContainer.textContent = '';
pdfWrapper.classList.add('hidden');
fileDisplayArea.innerHTML = '';
// dropZone.classList.remove('hidden');
const fileInput = document.getElementById('file-input') as HTMLInputElement;
if (fileInput) fileInput.value = '';
};
@@ -123,13 +117,10 @@ async function handleFiles(files: FileList) {
fileDisplayArea.appendChild(fileDiv);
createIcons({ icons });
// Clear previous content
pdfContainer.textContent = '';
if (currentPdfUrl) {
URL.revokeObjectURL(currentPdfUrl);
}
// Show editor container
pdfWrapper.classList.remove('hidden');
const fileURL = URL.createObjectURL(file);

View File

@@ -1,12 +1,9 @@
import { showAlert } from '../ui.js';
import { downloadFile, formatBytes, initializeQpdf, readFileAsArrayBuffer } from '../utils/helpers.js';
import { icons, createIcons } from 'lucide';
import { EncryptPdfState } from '@/types';
interface PageState {
file: File | null;
}
const pageState: PageState = {
const pageState: EncryptPdfState = {
file: null,
};

View File

@@ -0,0 +1,206 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
const FILETYPE = 'epub';
const EXTENSIONS = ['.epub'];
const TOOL_NAME = 'EPUB';
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const convertOptions = document.getElementById('convert-options');
// ... (existing listeners)
const updateUI = async () => {
if (!fileDisplayArea || !processBtn || !fileControls) return;
if (state.files.length > 0) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
fileControls.classList.remove('hidden');
if (convertOptions) convertOptions.classList.remove('hidden');
(processBtn as HTMLButtonElement).disabled = false;
} else {
fileDisplayArea.innerHTML = '';
fileControls.classList.add('hidden');
if (convertOptions) convertOptions.classList.add('hidden');
(processBtn as HTMLButtonElement).disabled = true;
}
};
const resetState = () => {
state.files = [];
state.pdfDoc = null;
updateUI();
};
const convertToPdf = async () => {
try {
if (state.files.length === 0) {
showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`);
return;
}
showLoader('Loading engine...');
const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
await pymupdf.load();
if (state.files.length === 1) {
const originalFile = state.files[0];
showLoader(`Converting ${originalFile.name}...`);
const pdfBlob = await pymupdf.convertToPdf(originalFile, { filetype: FILETYPE });
const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf';
downloadFile(pdfBlob, fileName);
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${originalFile.name} to PDF.`,
'success',
() => resetState()
);
} else {
showLoader('Converting files...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const pdfBlob = await pymupdf.convertToPdf(file, { filetype: FILETYPE });
const baseName = file.name.replace(/\.[^.]+$/, '');
const pdfBuffer = await pdfBlob.arrayBuffer();
zip.file(`${baseName}.pdf`, pdfBuffer);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${FILETYPE}-converted.zip`);
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`,
'success',
() => resetState()
);
}
} catch (e: any) {
console.error(`[${TOOL_NAME}2PDF] ERROR:`, e);
hideLoader();
showAlert('Error', `An error occurred during conversion. Error: ${e.message}`);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
state.files = [...state.files, ...Array.from(files)];
updateUI();
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const validFiles = Array.from(files).filter(f => {
const name = f.name.toLowerCase();
return EXTENSIONS.some(ext => name.endsWith(ext));
});
if (validFiles.length > 0) {
const dataTransfer = new DataTransfer();
validFiles.forEach(f => dataTransfer.items.add(f));
handleFileSelect(dataTransfer.files);
}
}
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput.click();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', () => {
resetState();
});
}
if (processBtn) {
processBtn.addEventListener('click', convertToPdf);
}
});

View File

@@ -0,0 +1,216 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import {
downloadFile,
readFileAsArrayBuffer,
formatBytes,
} from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { getLibreOfficeConverter, type LoadProgress } from '../utils/libreoffice-loader.js';
document.addEventListener('DOMContentLoaded', () => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const convertOptions = document.getElementById('convert-options');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const processBtn = document.getElementById('process-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!convertOptions) return;
if (state.files.length > 0) {
if (fileDisplayArea) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
}
if (fileControls) fileControls.classList.remove('hidden');
convertOptions.classList.remove('hidden');
} else {
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
if (fileControls) fileControls.classList.add('hidden');
convertOptions.classList.add('hidden');
}
};
const resetState = () => {
state.files = [];
state.pdfDoc = null;
updateUI();
};
const convertToPdf = async () => {
try {
if (state.files.length === 0) {
showAlert('No Files', 'Please select at least one Excel file.');
hideLoader();
return;
}
const converter = getLibreOfficeConverter();
// Initialize LibreOffice if not already done
await converter.initialize((progress: LoadProgress) => {
showLoader(progress.message, progress.percent);
});
if (state.files.length === 1) {
const originalFile = state.files[0];
showLoader('Processing...');
const pdfBlob = await converter.convertToPdf(originalFile);
const fileName = originalFile.name.replace(/\.(xls|xlsx|ods|csv)$/i, '') + '.pdf';
downloadFile(pdfBlob, fileName);
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${originalFile.name} to PDF.`,
'success',
() => resetState()
);
} else {
showLoader('Processing...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.(xls|xlsx|ods|csv)$/i, '');
const pdfBuffer = await pdfBlob.arrayBuffer();
zip.file(`${baseName}.pdf`, pdfBuffer);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'excel-converted.zip');
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} Excel file(s) to PDF.`,
'success',
() => resetState()
);
}
} catch (e: any) {
hideLoader();
showAlert(
'Error',
`An error occurred during conversion. Error: ${e.message}`
);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
state.files = [...state.files, ...Array.from(files)];
updateUI();
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const excelFiles = Array.from(files).filter(f => {
const name = f.name.toLowerCase();
return name.endsWith('.xls') || name.endsWith('.xlsx') || name.endsWith('.ods') || name.endsWith('.csv');
});
if (excelFiles.length > 0) {
const dataTransfer = new DataTransfer();
excelFiles.forEach(f => dataTransfer.items.add(f));
handleFileSelect(dataTransfer.files);
}
}
});
// Clear value on click to allow re-selecting the same file
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput.click();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', () => {
resetState();
});
}
if (processBtn) {
processBtn.addEventListener('click', convertToPdf);
}
});

View File

@@ -0,0 +1,281 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, readFileAsArrayBuffer, formatBytes, getPDFDocument } from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
interface ExtractedImage {
data: Uint8Array;
name: string;
ext: string;
}
let extractedImages: ExtractedImage[] = [];
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const fileDisplayArea = document.getElementById('file-display-area');
const extractOptions = document.getElementById('extract-options');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const imagesContainer = document.getElementById('images-container');
const imagesGrid = document.getElementById('images-grid');
const downloadAllBtn = document.getElementById('download-all-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!fileDisplayArea || !extractOptions || !processBtn || !fileControls) return;
// Clear extracted images when files change
extractedImages = [];
if (imagesContainer) imagesContainer.classList.add('hidden');
if (imagesGrid) imagesGrid.innerHTML = '';
if (state.files.length > 0) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`;
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_: File, i: number) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
metaSpan.textContent = `${formatBytes(file.size)}${pdfDoc.numPages} pages`;
} catch (error) {
metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`;
}
}
createIcons({ icons });
fileControls.classList.remove('hidden');
extractOptions.classList.remove('hidden');
(processBtn as HTMLButtonElement).disabled = false;
} else {
fileDisplayArea.innerHTML = '';
fileControls.classList.add('hidden');
extractOptions.classList.add('hidden');
(processBtn as HTMLButtonElement).disabled = true;
}
};
const resetState = () => {
state.files = [];
state.pdfDoc = null;
extractedImages = [];
if (imagesContainer) imagesContainer.classList.add('hidden');
if (imagesGrid) imagesGrid.innerHTML = '';
updateUI();
};
const displayImages = () => {
if (!imagesGrid || !imagesContainer) return;
imagesGrid.innerHTML = '';
extractedImages.forEach((img, index) => {
const blob = new Blob([new Uint8Array(img.data)]);
const url = URL.createObjectURL(blob);
const card = document.createElement('div');
card.className = 'bg-gray-700 rounded-lg overflow-hidden';
const imgEl = document.createElement('img');
imgEl.src = url;
imgEl.className = 'w-full h-32 object-cover';
const info = document.createElement('div');
info.className = 'p-2 flex justify-between items-center';
const name = document.createElement('span');
name.className = 'text-xs text-gray-300 truncate';
name.textContent = img.name;
const downloadBtn = document.createElement('button');
downloadBtn.className = 'text-indigo-400 hover:text-indigo-300';
downloadBtn.innerHTML = '<i data-lucide="download" class="w-4 h-4"></i>';
downloadBtn.onclick = () => {
downloadFile(blob, img.name);
};
info.append(name, downloadBtn);
card.append(imgEl, info);
imagesGrid.appendChild(card);
});
createIcons({ icons });
imagesContainer.classList.remove('hidden');
};
const extract = async () => {
try {
if (state.files.length === 0) {
showAlert('No Files', 'Please select at least one PDF file.');
return;
}
showLoader('Loading PDF processor...');
await pymupdf.load();
extractedImages = [];
let imgCounter = 0;
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Extracting images from ${file.name}...`);
const doc = await pymupdf.open(file);
const pageCount = doc.pageCount;
for (let pageIdx = 0; pageIdx < pageCount; pageIdx++) {
const page = doc.getPage(pageIdx);
const images = page.getImages();
for (const imgInfo of images) {
try {
const imgData = page.extractImage(imgInfo.xref);
if (imgData && imgData.data) {
imgCounter++;
extractedImages.push({
data: imgData.data,
name: `image_${imgCounter}.${imgData.ext || 'png'}`,
ext: imgData.ext || 'png'
});
}
} catch (e) {
console.warn('Failed to extract image:', e);
}
}
}
doc.close();
}
hideLoader();
if (extractedImages.length === 0) {
showAlert('No Images Found', 'No embedded images were found in the selected PDF(s).');
} else {
displayImages();
showAlert(
'Extraction Complete',
`Found ${extractedImages.length} image(s) in ${state.files.length} PDF(s).`,
'success'
);
}
} catch (e: any) {
hideLoader();
showAlert('Error', `An error occurred during extraction. Error: ${e.message}`);
}
};
const downloadAll = async () => {
if (extractedImages.length === 0) return;
showLoader('Creating ZIP archive...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
extractedImages.forEach((img) => {
zip.file(img.name, img.data);
});
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'extracted-images.zip');
hideLoader();
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const pdfFiles = Array.from(files).filter(
f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')
);
state.files = [...state.files, ...pdfFiles];
updateUI();
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
handleFileSelect(files);
}
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput.click();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', () => {
resetState();
});
}
if (processBtn) {
processBtn.addEventListener('click', extract);
}
if (downloadAllBtn) {
downloadAllBtn.addEventListener('click', downloadAll);
}
});

View File

@@ -0,0 +1,241 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
import JSZip from 'jszip';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
let file: File | null = null;
const updateUI = () => {
const fileDisplayArea = document.getElementById('file-display-area');
const optionsPanel = document.getElementById('options-panel');
if (!fileDisplayArea || !optionsPanel) return;
fileDisplayArea.innerHTML = '';
if (file) {
optionsPanel.classList.remove('hidden');
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = resetState;
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
createIcons({ icons });
} else {
optionsPanel.classList.add('hidden');
}
};
const resetState = () => {
file = null;
const fileInput = document.getElementById('file-input') as HTMLInputElement;
if (fileInput) fileInput.value = '';
updateUI();
};
function tableToCsv(rows: (string | null)[][]): string {
return rows.map(row =>
row.map(cell => {
const cellStr = cell ?? '';
if (cellStr.includes(',') || cellStr.includes('"') || cellStr.includes('\n')) {
return `"${cellStr.replace(/"/g, '""')}"`;
}
return cellStr;
}).join(',')
).join('\n');
}
async function extract() {
if (!file) {
showAlert('No File', 'Please upload a PDF file first.');
return;
}
const formatRadios = document.querySelectorAll('input[name="export-format"]');
let format = 'csv';
formatRadios.forEach((radio: Element) => {
if ((radio as HTMLInputElement).checked) {
format = (radio as HTMLInputElement).value;
}
});
showLoader('Loading Engine...');
try {
await pymupdf.load();
showLoader('Extracting tables...');
const doc = await pymupdf.open(file);
const pageCount = doc.pageCount;
const baseName = file.name.replace(/\.[^/.]+$/, '');
interface TableData {
page: number;
tableIndex: number;
rows: (string | null)[][];
markdown: string;
rowCount: number;
colCount: number;
}
const allTables: TableData[] = [];
for (let i = 0; i < pageCount; i++) {
showLoader(`Scanning page ${i + 1} of ${pageCount}...`);
const page = doc.getPage(i);
const tables = page.findTables();
tables.forEach((table, tableIdx) => {
allTables.push({
page: i + 1,
tableIndex: tableIdx + 1,
rows: table.rows,
markdown: table.markdown,
rowCount: table.rowCount,
colCount: table.colCount
});
});
}
if (allTables.length === 0) {
showAlert('No Tables Found', 'No tables were detected in this PDF.');
return;
}
if (allTables.length === 1) {
const table = allTables[0];
let content: string;
let ext: string;
let mimeType: string;
if (format === 'csv') {
content = tableToCsv(table.rows);
ext = 'csv';
mimeType = 'text/csv';
} else if (format === 'json') {
content = JSON.stringify(table.rows, null, 2);
ext = 'json';
mimeType = 'application/json';
} else {
content = table.markdown;
ext = 'md';
mimeType = 'text/markdown';
}
const blob = new Blob([content], { type: mimeType });
downloadFile(blob, `${baseName}_table.${ext}`);
showAlert('Success', `Extracted 1 table successfully!`, 'success', resetState);
} else {
showLoader('Creating ZIP file...');
const zip = new JSZip();
allTables.forEach((table, idx) => {
const filename = `table_${idx + 1}_page${table.page}`;
let content: string;
let ext: string;
if (format === 'csv') {
content = tableToCsv(table.rows);
ext = 'csv';
} else if (format === 'json') {
content = JSON.stringify(table.rows, null, 2);
ext = 'json';
} else {
content = table.markdown;
ext = 'md';
}
zip.file(`${filename}.${ext}`, content);
});
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${baseName}_tables.zip`);
showAlert('Success', `Extracted ${allTables.length} tables successfully!`, 'success', resetState);
}
} catch (e) {
console.error(e);
const message = e instanceof Error ? e.message : 'Unknown error';
showAlert('Error', `Failed to extract tables. ${message}`);
} finally {
hideLoader();
}
}
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const handleFileSelect = (newFiles: FileList | null) => {
if (!newFiles || newFiles.length === 0) return;
const validFile = Array.from(newFiles).find(f => f.type === 'application/pdf');
if (!validFile) {
showAlert('Invalid File', 'Please upload a PDF file.');
return;
}
file = validFile;
updateUI();
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (processBtn) {
processBtn.addEventListener('click', extract);
}
});

View File

@@ -0,0 +1,202 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
const FILETYPE = 'fb2';
const EXTENSIONS = ['.fb2'];
const TOOL_NAME = 'FB2';
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!fileDisplayArea || !processBtn || !fileControls) return;
if (state.files.length > 0) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
fileControls.classList.remove('hidden');
processBtn.classList.remove('hidden');
(processBtn as HTMLButtonElement).disabled = false;
} else {
fileDisplayArea.innerHTML = '';
fileControls.classList.add('hidden');
processBtn.classList.add('hidden');
(processBtn as HTMLButtonElement).disabled = true;
}
};
const resetState = () => {
state.files = [];
state.pdfDoc = null;
updateUI();
};
const convertToPdf = async () => {
try {
if (state.files.length === 0) {
showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`);
return;
}
showLoader('Loading engine...');
const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
await pymupdf.load();
if (state.files.length === 1) {
const originalFile = state.files[0];
showLoader(`Converting ${originalFile.name}...`);
const pdfBlob = await pymupdf.convertToPdf(originalFile, { filetype: FILETYPE });
const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf';
downloadFile(pdfBlob, fileName);
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${originalFile.name} to PDF.`,
'success',
() => resetState()
);
} else {
showLoader('Converting files...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const pdfBlob = await pymupdf.convertToPdf(file, { filetype: FILETYPE });
const baseName = file.name.replace(/\.[^.]+$/, '');
const pdfBuffer = await pdfBlob.arrayBuffer();
zip.file(`${baseName}.pdf`, pdfBuffer);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${FILETYPE}-converted.zip`);
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`,
'success',
() => resetState()
);
}
} catch (e: any) {
console.error(`[${TOOL_NAME}2PDF] ERROR:`, e);
hideLoader();
showAlert('Error', `An error occurred during conversion. Error: ${e.message}`);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
state.files = [...state.files, ...Array.from(files)];
updateUI();
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const validFiles = Array.from(files).filter(f => {
const name = f.name.toLowerCase();
return EXTENSIONS.some(ext => name.endsWith(ext));
});
if (validFiles.length > 0) {
const dataTransfer = new DataTransfer();
validFiles.forEach(f => dataTransfer.items.add(f));
handleFileSelect(dataTransfer.files);
}
}
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput.click();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', () => {
resetState();
});
}
if (processBtn) {
processBtn.addEventListener('click', convertToPdf);
}
});

View File

@@ -2,12 +2,9 @@ import { showAlert } from '../ui.js';
import { downloadFile, formatBytes, hexToRgb } from '../utils/helpers.js';
import { PDFDocument as PDFLibDocument, rgb, PageSizes } from 'pdf-lib';
import { icons, createIcons } from 'lucide';
import { FixPageSizeState } from '@/types';
interface PageState {
file: File | null;
}
const pageState: PageState = {
const pageState: FixPageSizeState = {
file: null,
};

View File

@@ -3,12 +3,9 @@ import { downloadFile, formatBytes, readFileAsArrayBuffer } from '../utils/helpe
import { PDFDocument } from 'pdf-lib';
import { icons, createIcons } from 'lucide';
import JSZip from 'jszip';
import { FlattenPdfState } from '@/types';
interface PageState {
files: File[];
}
const pageState: PageState = {
const pageState: FlattenPdfState = {
files: [],
};

View File

@@ -14,8 +14,8 @@ import { FormField, PageData } from '../types/index.js'
let fields: FormField[] = []
let selectedField: FormField | null = null
let fieldCounter = 0
let existingFieldNames: Set<string> = new Set()
let existingRadioGroups: Set<string> = new Set()
const existingFieldNames: Set<string> = new Set()
const existingRadioGroups: Set<string> = new Set()
let draggedElement: HTMLElement | null = null
let offsetX = 0
let offsetY = 0
@@ -2045,7 +2045,7 @@ async function renderCanvas(): Promise<void> {
if (!currentPage) return
// Fixed scale for better visibility
let scale = 1.333
const scale = 1.333
currentScale = scale

View File

@@ -85,7 +85,7 @@ function resetState() {
}
const toolUploader = document.getElementById('tool-uploader');
const isFullWidth = localStorage.getItem('fullWidthMode') === 'true';
const isFullWidth = localStorage.getItem('fullWidthMode') !== 'false';
if (toolUploader && !isFullWidth) {
toolUploader.classList.remove('max-w-6xl');
toolUploader.classList.add('max-w-2xl');
@@ -139,7 +139,8 @@ async function setupFormViewer() {
}
const toolUploader = document.getElementById('tool-uploader');
const isFullWidth = localStorage.getItem('fullWidthMode') === 'true';
// Default to true if not set
const isFullWidth = localStorage.getItem('fullWidthMode') !== 'false';
if (toolUploader && !isFullWidth) {
toolUploader.classList.remove('max-w-2xl');
toolUploader.classList.add('max-w-6xl');

View File

@@ -2,9 +2,9 @@ import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { downloadFile, hexToRgb, formatBytes, parsePageRanges } from '../utils/helpers.js';
import { PDFDocument as PDFLibDocument, rgb, StandardFonts } from 'pdf-lib';
import { HeaderFooterState } from '@/types';
interface PageState { file: File | null; pdfDoc: PDFLibDocument | null; }
const pageState: PageState = { file: null, pdfDoc: null };
const pageState: HeaderFooterState = { file: null, pdfDoc: null };
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);

View File

@@ -1,9 +1,15 @@
import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { downloadFile, readFileAsArrayBuffer, formatBytes } from '../utils/helpers.js';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
import heic2any from 'heic2any';
const SUPPORTED_FORMATS = '.jpg,.jpeg,.png,.bmp,.gif,.tiff,.tif,.pnm,.pgm,.pbm,.ppm,.pam,.jxr,.jpx,.jp2,.psd,.svg,.heic,.heif,.webp';
const SUPPORTED_FORMATS_DISPLAY = 'JPG, PNG, BMP, GIF, TIFF, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2, PSD, SVG, HEIC, WebP';
let files: File[] = [];
let pymupdf: PyMuPDF | null = null;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
@@ -19,8 +25,14 @@ function initializePage() {
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const processBtn = document.getElementById('process-btn');
const formatDisplay = document.getElementById('supported-formats');
if (formatDisplay) {
formatDisplay.textContent = SUPPORTED_FORMATS_DISPLAY;
}
if (fileInput) {
fileInput.accept = SUPPORTED_FORMATS;
fileInput.addEventListener('change', handleFileUpload);
}
@@ -43,7 +55,6 @@ function initializePage() {
}
});
// Clear value on click to allow re-selecting the same file
fileInput?.addEventListener('click', () => {
if (fileInput) fileInput.value = '';
});
@@ -78,13 +89,21 @@ function handleFileUpload(e: Event) {
}
}
function getFileExtension(filename: string): string {
return '.' + filename.split('.').pop()?.toLowerCase() || '';
}
function isValidImageFile(file: File): boolean {
const ext = getFileExtension(file.name);
const validExtensions = SUPPORTED_FORMATS.split(',');
return validExtensions.includes(ext) || file.type.startsWith('image/');
}
function handleFiles(newFiles: FileList) {
const validFiles = Array.from(newFiles).filter(file =>
file.type.startsWith('image/')
);
const validFiles = Array.from(newFiles).filter(isValidImageFile);
if (validFiles.length < newFiles.length) {
showAlert('Invalid Files', 'Some files were skipped. Only image files are allowed.');
showAlert('Invalid Files', 'Some files were skipped. Only supported image formats are allowed.');
}
if (validFiles.length > 0) {
@@ -146,95 +165,74 @@ function updateUI() {
}
}
function sanitizeImageAsJpeg(imageBytes: any) {
return new Promise((resolve, reject) => {
const blob = new Blob([imageBytes]);
const imageUrl = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
URL.revokeObjectURL(imageUrl);
return reject(new Error('Could not get canvas context'));
}
ctx.drawImage(img, 0, 0);
canvas.toBlob(
async (jpegBlob) => {
if (!jpegBlob) {
return reject(new Error('Canvas toBlob conversion failed.'));
}
const arrayBuffer = await jpegBlob.arrayBuffer();
resolve(new Uint8Array(arrayBuffer));
},
'image/jpeg',
0.9
);
URL.revokeObjectURL(imageUrl);
};
img.onerror = () => {
URL.revokeObjectURL(imageUrl);
reject(
new Error(
'The provided file could not be loaded as an image. It may be corrupted.'
)
);
};
img.src = imageUrl;
});
async function ensurePyMuPDF(): Promise<PyMuPDF> {
if (!pymupdf) {
pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
await pymupdf.load();
}
return pymupdf;
}
// Special handler for SVG files - must read as text
function svgToPng(svgText: string): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const img = new Image();
const svgBlob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
async function preprocessFile(file: File): Promise<File> {
const ext = getFileExtension(file.name);
img.onload = () => {
const canvas = document.createElement('canvas');
const width = img.naturalWidth || img.width || 800;
const height = img.naturalHeight || img.height || 600;
if (ext === '.heic' || ext === '.heif') {
try {
const conversionResult = await heic2any({
blob: file,
toType: 'image/png',
quality: 0.9,
});
canvas.width = width;
canvas.height = height;
const blob = Array.isArray(conversionResult) ? conversionResult[0] : conversionResult;
return new File([blob], file.name.replace(/\.(heic|heif)$/i, '.png'), { type: 'image/png' });
} catch (e) {
console.error(`Failed to convert HEIC: ${file.name}`, e);
throw new Error(`Failed to process HEIC file: ${file.name}`);
}
}
const ctx = canvas.getContext('2d');
if (!ctx) {
URL.revokeObjectURL(url);
return reject(new Error('Could not get canvas context'));
}
if (ext === '.webp') {
try {
return await new Promise<File>((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(
async (pngBlob) => {
URL.revokeObjectURL(url);
if (!pngBlob) {
return reject(new Error('Canvas toBlob conversion failed.'));
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
URL.revokeObjectURL(url);
reject(new Error('Canvas context failed'));
return;
}
const arrayBuffer = await pngBlob.arrayBuffer();
resolve(new Uint8Array(arrayBuffer));
},
'image/png'
);
};
ctx.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
URL.revokeObjectURL(url);
if (blob) {
resolve(new File([blob], file.name.replace(/\.webp$/i, '.png'), { type: 'image/png' }));
} else {
reject(new Error('Canvas toBlob failed'));
}
}, 'image/png');
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load SVG image'));
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load WebP image'));
};
img.src = url;
});
img.src = url;
});
} catch (e) {
console.error(`Failed to convert WebP: ${file.name}`, e);
throw new Error(`Failed to process WebP file: ${file.name}`);
}
}
return file;
}
async function convertToPdf() {
@@ -243,78 +241,34 @@ async function convertToPdf() {
return;
}
showLoader('Creating PDF from images...');
showLoader('Processing images...');
try {
const pdfDoc = await PDFLibDocument.create();
const processedFiles: File[] = [];
for (const file of files) {
try {
const isSvg = file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg');
if (isSvg) {
// Handle SVG files - read as text
const svgText = await file.text();
const pngBytes = await svgToPng(svgText);
const pngImage = await pdfDoc.embedPng(pngBytes);
const page = pdfDoc.addPage([pngImage.width, pngImage.height]);
page.drawImage(pngImage, {
x: 0,
y: 0,
width: pngImage.width,
height: pngImage.height,
});
} else if (file.type === 'image/png') {
// Handle PNG files
const originalBytes = await readFileAsArrayBuffer(file);
const pngImage = await pdfDoc.embedPng(originalBytes as Uint8Array);
const page = pdfDoc.addPage([pngImage.width, pngImage.height]);
page.drawImage(pngImage, {
x: 0,
y: 0,
width: pngImage.width,
height: pngImage.height,
});
} else {
// Handle JPG/other raster images
const originalBytes = await readFileAsArrayBuffer(file);
let jpgImage;
try {
jpgImage = await pdfDoc.embedJpg(originalBytes as Uint8Array);
} catch (e) {
// Fallback: convert to JPEG via canvas
const sanitizedBytes = await sanitizeImageAsJpeg(originalBytes);
jpgImage = await pdfDoc.embedJpg(sanitizedBytes as Uint8Array);
}
const page = pdfDoc.addPage([jpgImage.width, jpgImage.height]);
page.drawImage(jpgImage, {
x: 0,
y: 0,
width: jpgImage.width,
height: jpgImage.height,
});
}
} catch (error) {
console.error(`Failed to process ${file.name}:`, error);
throw new Error(`Could not process "${file.name}". The file may be corrupted.`);
const processed = await preprocessFile(file);
processedFiles.push(processed);
} catch (error: any) {
console.warn(error);
throw error;
}
}
const pdfBytes = await pdfDoc.save();
downloadFile(
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
'from_images.pdf'
);
showLoader('Loading engine...');
const mupdf = await ensurePyMuPDF();
showLoader('Converting images to PDF...');
const pdfBlob = await mupdf.imagesToPdf(processedFiles);
downloadFile(pdfBlob, 'images_to_pdf.pdf');
showAlert('Success', 'PDF created successfully!', 'success', () => {
resetState();
});
} catch (e: any) {
console.error(e);
showAlert('Conversion Error', e.message);
console.error('[ImageToPDF]', e);
showAlert('Conversion Error', e.message || 'Failed to convert images to PDF.');
} finally {
hideLoader();
}

View File

@@ -3,11 +3,11 @@ import { showAlert, showLoader, hideLoader } from '../ui.js';
import { downloadFile, formatBytes, getPDFDocument } from '../utils/helpers.js';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
import { InvertColorsState } from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
interface PageState { file: File | null; pdfDoc: PDFLibDocument | null; }
const pageState: PageState = { file: null, pdfDoc: null };
const pageState: InvertColorsState = { file: null, pdfDoc: null };
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializePage); }
else { initializePage(); }

View File

@@ -1,9 +1,14 @@
import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { downloadFile, readFileAsArrayBuffer, formatBytes } from '../utils/helpers.js';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
const SUPPORTED_FORMATS = '.jpg,.jpeg,.jp2,.jpx';
const SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/jpg', 'image/jp2'];
let files: File[] = [];
let pymupdf: PyMuPDF | null = null;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
@@ -43,7 +48,6 @@ function initializePage() {
}
});
// Clear value on click to allow re-selecting the same file
fileInput?.addEventListener('click', () => {
if (fileInput) fileInput.value = '';
});
@@ -78,13 +82,21 @@ function handleFileUpload(e: Event) {
}
}
function getFileExtension(filename: string): string {
return '.' + (filename.split('.').pop()?.toLowerCase() || '');
}
function isValidImageFile(file: File): boolean {
const ext = getFileExtension(file.name);
const validExtensions = SUPPORTED_FORMATS.split(',');
return validExtensions.includes(ext) || SUPPORTED_MIME_TYPES.includes(file.type);
}
function handleFiles(newFiles: FileList) {
const validFiles = Array.from(newFiles).filter(file =>
file.type === 'image/jpeg' || file.type === 'image/jpg' || file.name.toLowerCase().endsWith('.jpg') || file.name.toLowerCase().endsWith('.jpeg')
);
const validFiles = Array.from(newFiles).filter(isValidImageFile);
if (validFiles.length < newFiles.length) {
showAlert('Invalid Files', 'Some files were skipped. Only JPG/JPEG images are allowed.');
showAlert('Invalid Files', 'Some files were skipped. Only JPG, JPEG, JP2, and JPX files are allowed.');
}
if (validFiles.length > 0) {
@@ -146,102 +158,37 @@ function updateUI() {
}
}
function sanitizeImageAsJpeg(imageBytes: any) {
return new Promise((resolve, reject) => {
const blob = new Blob([imageBytes]);
const imageUrl = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
canvas.toBlob(
async (jpegBlob) => {
if (!jpegBlob) {
return reject(new Error('Canvas toBlob conversion failed.'));
}
const arrayBuffer = await jpegBlob.arrayBuffer();
resolve(new Uint8Array(arrayBuffer));
},
'image/jpeg',
0.9
);
URL.revokeObjectURL(imageUrl);
};
img.onerror = () => {
URL.revokeObjectURL(imageUrl);
reject(
new Error(
'The provided file could not be loaded as an image. It may be corrupted.'
)
);
};
img.src = imageUrl;
});
async function ensurePyMuPDF(): Promise<PyMuPDF> {
if (!pymupdf) {
pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
await pymupdf.load();
}
return pymupdf;
}
async function convertToPdf() {
if (files.length === 0) {
showAlert('No Files', 'Please select at least one JPG file.');
showAlert('No Files', 'Please select at least one JPG or JPEG2000 image.');
return;
}
showLoader('Creating PDF from JPGs...');
showLoader('Loading engine...');
try {
const pdfDoc = await PDFLibDocument.create();
const mupdf = await ensurePyMuPDF();
for (const file of files) {
const originalBytes = await readFileAsArrayBuffer(file);
let jpgImage;
showLoader('Converting images to PDF...');
try {
jpgImage = await pdfDoc.embedJpg(originalBytes as Uint8Array);
} catch (e) {
showAlert(
'Warning',
`Direct JPG embedding failed for ${file.name}, attempting to sanitize...`
);
try {
const sanitizedBytes = await sanitizeImageAsJpeg(originalBytes);
jpgImage = await pdfDoc.embedJpg(sanitizedBytes as Uint8Array);
} catch (fallbackError) {
console.error(
`Failed to process ${file.name} after sanitization:`,
fallbackError
);
throw new Error(
`Could not process "${file.name}". The file may be corrupted.`
);
}
}
const pdfBlob = await mupdf.imagesToPdf(files);
const page = pdfDoc.addPage([jpgImage.width, jpgImage.height]);
page.drawImage(jpgImage, {
x: 0,
y: 0,
width: jpgImage.width,
height: jpgImage.height,
});
}
downloadFile(pdfBlob, 'from_jpgs.pdf');
const pdfBytes = await pdfDoc.save();
downloadFile(
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
'from_jpgs.pdf'
);
showAlert('Success', 'PDF created successfully!', 'success', () => {
resetState();
});
} catch (e: any) {
console.error(e);
showAlert('Conversion Error', e.message);
console.error('[JpgToPdf]', e);
showAlert('Conversion Error', e.message || 'Failed to convert images to PDF.');
} finally {
hideLoader();
}

View File

@@ -2,12 +2,9 @@ import { showAlert } from '../ui.js';
import { downloadFile, formatBytes, initializeQpdf, readFileAsArrayBuffer } from '../utils/helpers.js';
import { icons, createIcons } from 'lucide';
import JSZip from 'jszip';
import { LinearizePdfState } from '@/types';
interface PageState {
files: File[];
}
const pageState: PageState = {
const pageState: LinearizePdfState = {
files: [],
};

View File

@@ -0,0 +1,21 @@
import { MarkdownEditor } from '../utils/markdown-editor.js';
document.addEventListener('DOMContentLoaded', () => {
const container = document.getElementById('markdown-editor-container');
if (!container) {
console.error('Markdown editor container not found');
return;
}
const editor = new MarkdownEditor(container, {});
console.log('Markdown editor initialized');
const backButton = document.getElementById('back-to-tools');
if (backButton) {
backButton.addEventListener('click', () => {
window.location.href = '/';
});
}
});

View File

@@ -0,0 +1,202 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
const FILETYPE = 'mobi';
const EXTENSIONS = ['.mobi'];
const TOOL_NAME = 'MOBI';
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!fileDisplayArea || !processBtn || !fileControls) return;
if (state.files.length > 0) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
fileControls.classList.remove('hidden');
processBtn.classList.remove('hidden');
(processBtn as HTMLButtonElement).disabled = false;
} else {
fileDisplayArea.innerHTML = '';
fileControls.classList.add('hidden');
processBtn.classList.add('hidden');
(processBtn as HTMLButtonElement).disabled = true;
}
};
const resetState = () => {
state.files = [];
state.pdfDoc = null;
updateUI();
};
const convertToPdf = async () => {
try {
if (state.files.length === 0) {
showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`);
return;
}
showLoader('Loading engine...');
const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
await pymupdf.load();
if (state.files.length === 1) {
const originalFile = state.files[0];
showLoader(`Converting ${originalFile.name}...`);
const pdfBlob = await pymupdf.convertToPdf(originalFile, { filetype: FILETYPE });
const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf';
downloadFile(pdfBlob, fileName);
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${originalFile.name} to PDF.`,
'success',
() => resetState()
);
} else {
showLoader('Converting files...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const pdfBlob = await pymupdf.convertToPdf(file, { filetype: FILETYPE });
const baseName = file.name.replace(/\.[^.]+$/, '');
const pdfBuffer = await pdfBlob.arrayBuffer();
zip.file(`${baseName}.pdf`, pdfBuffer);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${FILETYPE}-converted.zip`);
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`,
'success',
() => resetState()
);
}
} catch (e: any) {
console.error(`[${TOOL_NAME}2PDF] ERROR:`, e);
hideLoader();
showAlert('Error', `An error occurred during conversion. Error: ${e.message}`);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
state.files = [...state.files, ...Array.from(files)];
updateUI();
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const validFiles = Array.from(files).filter(f => {
const name = f.name.toLowerCase();
return EXTENSIONS.some(ext => name.endsWith(ext));
});
if (validFiles.length > 0) {
const dataTransfer = new DataTransfer();
validFiles.forEach(f => dataTransfer.items.add(f));
handleFileSelect(dataTransfer.files);
}
}
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput.click();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', () => {
resetState();
});
}
if (processBtn) {
processBtn.addEventListener('click', convertToPdf);
}
});

View File

@@ -7,20 +7,10 @@ import fontkit from '@pdf-lib/fontkit';
import { icons, createIcons } from 'lucide';
import * as pdfjsLib from 'pdfjs-dist';
import { getFontForLanguage } from '../utils/font-loader.js';
import { OcrWord, OcrState } from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
interface Word {
text: string;
bbox: { x0: number; y0: number; x1: number; y1: number };
confidence: number;
}
interface OcrState {
file: File | null;
searchablePdfBytes: Uint8Array | null;
}
const pageState: OcrState = {
file: null,
searchablePdfBytes: null,
@@ -35,10 +25,10 @@ const whitelistPresets: Record<string, string> = {
forms: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,()-_/@#:',
};
function parseHOCR(hocrText: string): Word[] {
function parseHOCR(hocrText: string): OcrWord[] {
const parser = new DOMParser();
const doc = parser.parseFromString(hocrText, 'text/html');
const words: Word[] = [];
const words: OcrWord[] = [];
const wordElements = doc.querySelectorAll('.ocrx_word');
@@ -264,7 +254,7 @@ async function runOCR() {
if (data.hocr) {
const words = parseHOCR(data.hocr);
words.forEach(function (word: Word) {
words.forEach(function (word: OcrWord) {
const { x0, y0, x1, y1 } = word.bbox;
const text = word.text.replace(/[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\uFEFF]/g, '');

View File

@@ -0,0 +1,189 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import {
downloadFile,
formatBytes,
} from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { getLibreOfficeConverter, type LoadProgress } from '../utils/libreoffice-loader.js';
const ACCEPTED_EXTENSIONS = ['.odg'];
const FILETYPE_NAME = 'ODG';
document.addEventListener('DOMContentLoaded', () => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const convertOptions = document.getElementById('convert-options');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const processBtn = document.getElementById('process-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!convertOptions) return;
if (state.files.length > 0) {
if (fileDisplayArea) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
}
if (fileControls) fileControls.classList.remove('hidden');
convertOptions.classList.remove('hidden');
} else {
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
if (fileControls) fileControls.classList.add('hidden');
convertOptions.classList.add('hidden');
}
};
const resetState = () => {
state.files = [];
updateUI();
};
const convert = async () => {
if (state.files.length === 0) {
showAlert('No Files', `Please select at least one ${FILETYPE_NAME} file.`);
return;
}
try {
const converter = getLibreOfficeConverter();
showLoader('Loading engine...');
await converter.initialize((progress: LoadProgress) => {
showLoader(progress.message, progress.percent);
});
if (state.files.length === 1) {
const file = state.files[0];
showLoader(`Converting ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
downloadFile(pdfBlob, `${baseName}.pdf`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${file.name} to PDF.`, 'success', () => resetState());
} else {
showLoader('Converting multiple files...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
zip.file(`${baseName}.pdf`, pdfBlob);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${FILETYPE_NAME.toLowerCase()}-to-pdf.zip`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${state.files.length} files to PDF.`, 'success', () => resetState());
}
} catch (err) {
hideLoader();
const message = err instanceof Error ? err.message : 'Unknown error';
console.error(`[${FILETYPE_NAME}ToPDF] Error:`, err);
showAlert('Error', `An error occurred during conversion. Error: ${message}`);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const validFiles = Array.from(files).filter(file => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
return ACCEPTED_EXTENSIONS.includes(ext);
});
if (validFiles.length > 0) {
state.files = [...state.files, ...validFiles];
updateUI();
}
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => fileInput.click());
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', resetState);
}
if (processBtn) {
processBtn.addEventListener('click', convert);
}
updateUI();
});

View File

@@ -0,0 +1,189 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import {
downloadFile,
formatBytes,
} from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { getLibreOfficeConverter, type LoadProgress } from '../utils/libreoffice-loader.js';
const ACCEPTED_EXTENSIONS = ['.odp'];
const FILETYPE_NAME = 'ODP';
document.addEventListener('DOMContentLoaded', () => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const convertOptions = document.getElementById('convert-options');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const processBtn = document.getElementById('process-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!convertOptions) return;
if (state.files.length > 0) {
if (fileDisplayArea) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
}
if (fileControls) fileControls.classList.remove('hidden');
convertOptions.classList.remove('hidden');
} else {
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
if (fileControls) fileControls.classList.add('hidden');
convertOptions.classList.add('hidden');
}
};
const resetState = () => {
state.files = [];
updateUI();
};
const convert = async () => {
if (state.files.length === 0) {
showAlert('No Files', `Please select at least one ${FILETYPE_NAME} file.`);
return;
}
try {
const converter = getLibreOfficeConverter();
showLoader('Loading engine...');
await converter.initialize((progress: LoadProgress) => {
showLoader(progress.message, progress.percent);
});
if (state.files.length === 1) {
const file = state.files[0];
showLoader(`Converting ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
downloadFile(pdfBlob, `${baseName}.pdf`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${file.name} to PDF.`, 'success', () => resetState());
} else {
showLoader('Converting multiple files...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
zip.file(`${baseName}.pdf`, pdfBlob);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${FILETYPE_NAME.toLowerCase()}-to-pdf.zip`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${state.files.length} files to PDF.`, 'success', () => resetState());
}
} catch (err) {
hideLoader();
const message = err instanceof Error ? err.message : 'Unknown error';
console.error(`[${FILETYPE_NAME}ToPDF] Error:`, err);
showAlert('Error', `An error occurred during conversion. Error: ${message}`);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const validFiles = Array.from(files).filter(file => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
return ACCEPTED_EXTENSIONS.includes(ext);
});
if (validFiles.length > 0) {
state.files = [...state.files, ...validFiles];
updateUI();
}
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => fileInput.click());
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', resetState);
}
if (processBtn) {
processBtn.addEventListener('click', convert);
}
updateUI();
});

View File

@@ -0,0 +1,189 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import {
downloadFile,
formatBytes,
} from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { getLibreOfficeConverter, type LoadProgress } from '../utils/libreoffice-loader.js';
const ACCEPTED_EXTENSIONS = ['.ods'];
const FILETYPE_NAME = 'ODS';
document.addEventListener('DOMContentLoaded', () => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const convertOptions = document.getElementById('convert-options');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const processBtn = document.getElementById('process-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!convertOptions) return;
if (state.files.length > 0) {
if (fileDisplayArea) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
}
if (fileControls) fileControls.classList.remove('hidden');
convertOptions.classList.remove('hidden');
} else {
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
if (fileControls) fileControls.classList.add('hidden');
convertOptions.classList.add('hidden');
}
};
const resetState = () => {
state.files = [];
updateUI();
};
const convert = async () => {
if (state.files.length === 0) {
showAlert('No Files', `Please select at least one ${FILETYPE_NAME} file.`);
return;
}
try {
const converter = getLibreOfficeConverter();
showLoader('Loading engine...');
await converter.initialize((progress: LoadProgress) => {
showLoader(progress.message, progress.percent);
});
if (state.files.length === 1) {
const file = state.files[0];
showLoader(`Converting ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
downloadFile(pdfBlob, `${baseName}.pdf`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${file.name} to PDF.`, 'success', () => resetState());
} else {
showLoader('Converting multiple files...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
zip.file(`${baseName}.pdf`, pdfBlob);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${FILETYPE_NAME.toLowerCase()}-to-pdf.zip`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${state.files.length} files to PDF.`, 'success', () => resetState());
}
} catch (err) {
hideLoader();
const message = err instanceof Error ? err.message : 'Unknown error';
console.error(`[${FILETYPE_NAME}ToPDF] Error:`, err);
showAlert('Error', `An error occurred during conversion. Error: ${message}`);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const validFiles = Array.from(files).filter(file => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
return ACCEPTED_EXTENSIONS.includes(ext);
});
if (validFiles.length > 0) {
state.files = [...state.files, ...validFiles];
updateUI();
}
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => fileInput.click());
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', resetState);
}
if (processBtn) {
processBtn.addEventListener('click', convert);
}
updateUI();
});

View File

@@ -0,0 +1,215 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import {
downloadFile,
readFileAsArrayBuffer,
formatBytes,
} from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { getLibreOfficeConverter, type LoadProgress } from '../utils/libreoffice-loader.js';
document.addEventListener('DOMContentLoaded', () => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const convertOptions = document.getElementById('convert-options');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const processBtn = document.getElementById('process-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!convertOptions) return;
if (state.files.length > 0) {
if (fileDisplayArea) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
}
if (fileControls) fileControls.classList.remove('hidden');
convertOptions.classList.remove('hidden');
} else {
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
if (fileControls) fileControls.classList.add('hidden');
convertOptions.classList.add('hidden');
}
};
const resetState = () => {
state.files = [];
state.pdfDoc = null;
updateUI();
};
const convertToPdf = async () => {
try {
if (state.files.length === 0) {
showAlert('No Files', 'Please select at least one ODT file.');
hideLoader();
return;
}
const converter = getLibreOfficeConverter();
// Initialize LibreOffice if not already done
await converter.initialize((progress: LoadProgress) => {
showLoader(progress.message, progress.percent);
});
if (state.files.length === 1) {
const originalFile = state.files[0];
showLoader('Processing...');
const pdfBlob = await converter.convertToPdf(originalFile);
const fileName = originalFile.name.replace(/\.odt$/i, '') + '.pdf';
downloadFile(pdfBlob, fileName);
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${originalFile.name} to PDF.`,
'success',
() => resetState()
);
} else {
showLoader('Processing...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.odt$/i, '');
const pdfBuffer = await pdfBlob.arrayBuffer();
zip.file(`${baseName}.pdf`, pdfBuffer);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'odt-converted.zip');
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} ODT file(s) to PDF.`,
'success',
() => resetState()
);
}
} catch (e: any) {
hideLoader();
showAlert(
'Error',
`An error occurred during conversion. Error: ${e.message}`
);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
state.files = [...state.files, ...Array.from(files)];
updateUI();
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const odtFiles = Array.from(files).filter(f => f.name.toLowerCase().endsWith('.odt') || f.type === 'application/vnd.oasis.opendocument.text');
if (odtFiles.length > 0) {
const dataTransfer = new DataTransfer();
odtFiles.forEach(f => dataTransfer.items.add(f));
handleFileSelect(dataTransfer.files);
}
}
});
// Clear value on click to allow re-selecting the same file
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput.click();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', () => {
resetState();
});
}
if (processBtn) {
processBtn.addEventListener('click', convertToPdf);
}
updateUI();
});

View File

@@ -2,13 +2,9 @@ import { showAlert } from '../ui.js';
import { formatBytes, getStandardPageName, convertPoints } from '../utils/helpers.js';
import { PDFDocument } from 'pdf-lib';
import { icons, createIcons } from 'lucide';
import { PageDimensionsState } from '@/types';
interface PageState {
file: File | null;
pdfDoc: PDFDocument | null;
}
const pageState: PageState = {
const pageState: PageDimensionsState = {
file: null,
pdfDoc: null,
};

View File

@@ -162,7 +162,7 @@ async function addPageNumbers() {
const xOffset = bounds.x || 0;
const yOffset = bounds.y || 0;
let pageNumText = format === 'page_x_of_y' ? `${i + 1} / ${totalPages}` : `${i + 1}`;
const pageNumText = format === 'page_x_of_y' ? `${i + 1} / ${totalPages}` : `${i + 1}`;
const textWidth = helveticaFont.widthOfTextAtSize(pageNumText, fontSize);
const textHeight = fontSize;

View File

@@ -0,0 +1,188 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import {
downloadFile,
formatBytes,
} from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { getLibreOfficeConverter, type LoadProgress } from '../utils/libreoffice-loader.js';
const ACCEPTED_EXTENSIONS = ['.pages'];
const FILETYPE_NAME = 'Pages';
document.addEventListener('DOMContentLoaded', () => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const convertOptions = document.getElementById('convert-options');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const processBtn = document.getElementById('process-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!convertOptions) return;
if (state.files.length > 0) {
if (fileDisplayArea) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
}
if (fileControls) fileControls.classList.remove('hidden');
convertOptions.classList.remove('hidden');
} else {
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
if (fileControls) fileControls.classList.add('hidden');
convertOptions.classList.add('hidden');
}
};
const resetState = () => {
state.files = [];
updateUI();
};
const convert = async () => {
if (state.files.length === 0) {
showAlert('No Files', `Please select at least one ${FILETYPE_NAME} file.`);
return;
}
try {
const converter = getLibreOfficeConverter();
showLoader('Loading engine...');
await converter.initialize((progress: LoadProgress) => {
showLoader(progress.message, progress.percent);
});
if (state.files.length === 1) {
const file = state.files[0];
showLoader(`Converting ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
downloadFile(pdfBlob, `${baseName}.pdf`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${file.name} to PDF.`, 'success', () => resetState());
} else {
showLoader('Converting multiple files...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
zip.file(`${baseName}.pdf`, pdfBlob);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${FILETYPE_NAME.toLowerCase()}-to-pdf.zip`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${state.files.length} files to PDF.`, 'success', () => resetState());
}
} catch (e: any) {
hideLoader();
console.error(`[${FILETYPE_NAME}ToPDF] Error:`, e);
showAlert('Error', `An error occurred during conversion. Error: ${e.message}`);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const validFiles = Array.from(files).filter(file => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
return ACCEPTED_EXTENSIONS.includes(ext);
});
if (validFiles.length > 0) {
state.files = [...state.files, ...validFiles];
updateUI();
}
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => fileInput.click());
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', resetState);
}
if (processBtn) {
processBtn.addEventListener('click', convert);
}
updateUI();
});

View File

@@ -0,0 +1,517 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
import { PDFDocument as PDFLibDocument, degrees, PageSizes } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.mjs',
import.meta.url
).toString();
interface BookletState {
file: File | null;
pdfDoc: PDFLibDocument | null;
pdfBytes: Uint8Array | null;
pdfjsDoc: pdfjsLib.PDFDocumentProxy | null;
}
const pageState: BookletState = {
file: null,
pdfDoc: null,
pdfBytes: null,
pdfjsDoc: null,
};
function resetState() {
pageState.file = null;
pageState.pdfDoc = null;
pageState.pdfBytes = null;
pageState.pdfjsDoc = null;
const fileDisplayArea = document.getElementById('file-display-area');
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
const toolOptions = document.getElementById('tool-options');
if (toolOptions) toolOptions.classList.add('hidden');
const fileInput = document.getElementById('file-input') as HTMLInputElement;
if (fileInput) fileInput.value = '';
const previewArea = document.getElementById('booklet-preview');
if (previewArea) previewArea.innerHTML = '<p class="text-gray-400 text-center py-8">Upload a PDF and click "Generate Preview" to see the booklet layout</p>';
const downloadBtn = document.getElementById('download-btn') as HTMLButtonElement;
if (downloadBtn) downloadBtn.disabled = true;
}
async function updateUI() {
const fileDisplayArea = document.getElementById('file-display-area');
const toolOptions = document.getElementById('tool-options');
if (!fileDisplayArea) return;
fileDisplayArea.innerHTML = '';
if (pageState.file) {
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = pageState.file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(pageState.file.size)} • Loading...`;
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = function () {
resetState();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
createIcons({ icons });
try {
showLoader('Loading PDF...');
const arrayBuffer = await pageState.file.arrayBuffer();
pageState.pdfBytes = new Uint8Array(arrayBuffer);
pageState.pdfDoc = await PDFLibDocument.load(pageState.pdfBytes, {
ignoreEncryption: true,
throwOnInvalidObject: false
});
pageState.pdfjsDoc = await pdfjsLib.getDocument({ data: pageState.pdfBytes.slice() }).promise;
hideLoader();
const pageCount = pageState.pdfDoc.getPageCount();
metaSpan.textContent = `${formatBytes(pageState.file.size)}${pageCount} pages`;
if (toolOptions) toolOptions.classList.remove('hidden');
const previewBtn = document.getElementById('preview-btn') as HTMLButtonElement;
if (previewBtn) previewBtn.disabled = false;
} catch (error) {
console.error('Error loading PDF:', error);
hideLoader();
showAlert('Error', 'Failed to load PDF file.');
resetState();
}
} else {
if (toolOptions) toolOptions.classList.add('hidden');
}
}
function getGridDimensions(): { rows: number; cols: number } {
const gridMode = (document.querySelector('input[name="grid-mode"]:checked') as HTMLInputElement)?.value || '1x2';
switch (gridMode) {
case '1x2': return { rows: 1, cols: 2 };
case '2x2': return { rows: 2, cols: 2 };
case '2x4': return { rows: 2, cols: 4 };
case '4x4': return { rows: 4, cols: 4 };
default: return { rows: 1, cols: 2 };
}
}
function getOrientation(isBookletMode: boolean): 'portrait' | 'landscape' {
const orientationValue = (document.querySelector('input[name="orientation"]:checked') as HTMLInputElement)?.value || 'auto';
if (orientationValue === 'portrait') return 'portrait';
if (orientationValue === 'landscape') return 'landscape';
return isBookletMode ? 'landscape' : 'portrait';
}
function getSheetDimensions(isBookletMode: boolean): { width: number; height: number } {
const paperSizeKey = (document.getElementById('paper-size') as HTMLSelectElement).value as keyof typeof PageSizes;
const pageDims = PageSizes[paperSizeKey] || PageSizes.Letter;
const orientation = getOrientation(isBookletMode);
if (orientation === 'landscape') {
return { width: pageDims[1], height: pageDims[0] };
}
return { width: pageDims[0], height: pageDims[1] };
}
async function generatePreview() {
if (!pageState.pdfDoc || !pageState.pdfjsDoc) {
showAlert('Error', 'Please load a PDF first.');
return;
}
const previewArea = document.getElementById('booklet-preview')!;
const totalPages = pageState.pdfDoc.getPageCount();
const { rows, cols } = getGridDimensions();
const pagesPerSheet = rows * cols;
const isBookletMode = rows === 1 && cols === 2;
let numSheets: number;
if (isBookletMode) {
const sheetsNeeded = Math.ceil(totalPages / 4);
numSheets = sheetsNeeded * 2;
} else {
numSheets = Math.ceil(totalPages / pagesPerSheet);
}
const { width: sheetWidth, height: sheetHeight } = getSheetDimensions(isBookletMode);
// Get container width to make canvas fill it
const previewContainer = document.getElementById('booklet-preview')!;
const containerWidth = previewContainer.clientWidth - 32; // account for padding
const aspectRatio = sheetWidth / sheetHeight;
const canvasWidth = containerWidth;
const canvasHeight = containerWidth / aspectRatio;
previewArea.innerHTML = '<p class="text-gray-400 text-center py-4">Generating preview...</p>';
const totalRounded = isBookletMode ? Math.ceil(totalPages / 4) * 4 : totalPages;
const rotationMode = (document.querySelector('input[name="rotation"]:checked') as HTMLInputElement)?.value || 'none';
const pageThumbnails: Map<number, ImageBitmap> = new Map();
const thumbnailScale = 0.3;
for (let i = 1; i <= totalPages; i++) {
try {
const page = await pageState.pdfjsDoc.getPage(i);
const viewport = page.getViewport({ scale: thumbnailScale });
const offscreen = new OffscreenCanvas(viewport.width, viewport.height);
const ctx = offscreen.getContext('2d')!;
await page.render({
canvasContext: ctx as any,
viewport: viewport,
canvas: offscreen as any,
}).promise;
const bitmap = await createImageBitmap(offscreen);
pageThumbnails.set(i, bitmap);
} catch (e) {
console.error(`Failed to render page ${i}:`, e);
}
}
previewArea.innerHTML = `<p class="text-indigo-400 text-sm mb-4 text-center">${totalPages} pages → ${numSheets} output sheets</p>`;
for (let sheetIndex = 0; sheetIndex < numSheets; sheetIndex++) {
const canvas = document.createElement('canvas');
canvas.width = canvasWidth;
canvas.height = canvasHeight;
canvas.className = 'border border-gray-600 rounded-lg mb-4';
const ctx = canvas.getContext('2d')!;
const isFront = sheetIndex % 2 === 0;
ctx.fillStyle = isFront ? '#1f2937' : '#1a2e1a';
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
ctx.strokeStyle = '#4b5563';
ctx.lineWidth = 1;
ctx.strokeRect(0, 0, canvasWidth, canvasHeight);
const cellWidth = canvasWidth / cols;
const cellHeight = canvasHeight / rows;
const padding = 4;
ctx.strokeStyle = '#374151';
ctx.lineWidth = 1;
ctx.setLineDash([2, 2]);
for (let c = 1; c < cols; c++) {
ctx.beginPath();
ctx.moveTo(c * cellWidth, 0);
ctx.lineTo(c * cellWidth, canvasHeight);
ctx.stroke();
}
for (let r = 1; r < rows; r++) {
ctx.beginPath();
ctx.moveTo(0, r * cellHeight);
ctx.lineTo(canvasWidth, r * cellHeight);
ctx.stroke();
}
ctx.setLineDash([]);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const slotIndex = r * cols + c;
let pageNumber: number;
if (isBookletMode) {
const physicalSheet = Math.floor(sheetIndex / 2);
const isFrontSide = sheetIndex % 2 === 0;
if (isFrontSide) {
pageNumber = c === 0 ? totalRounded - 2 * physicalSheet : 2 * physicalSheet + 1;
} else {
pageNumber = c === 0 ? 2 * physicalSheet + 2 : totalRounded - 2 * physicalSheet - 1;
}
} else {
pageNumber = sheetIndex * pagesPerSheet + slotIndex + 1;
}
const x = c * cellWidth + padding;
const y = r * cellHeight + padding;
const slotWidth = cellWidth - padding * 2;
const slotHeight = cellHeight - padding * 2;
const exists = pageNumber >= 1 && pageNumber <= totalPages;
if (exists) {
const thumbnail = pageThumbnails.get(pageNumber);
if (thumbnail) {
let rotation = 0;
if (rotationMode === '90cw') rotation = 90;
else if (rotationMode === '90ccw') rotation = -90;
else if (rotationMode === 'alternate') rotation = (pageNumber % 2 === 1) ? 90 : -90;
const isRotated = rotation !== 0;
const srcWidth = isRotated ? thumbnail.height : thumbnail.width;
const srcHeight = isRotated ? thumbnail.width : thumbnail.height;
const scale = Math.min(slotWidth / srcWidth, slotHeight / srcHeight);
const drawWidth = srcWidth * scale;
const drawHeight = srcHeight * scale;
const drawX = x + (slotWidth - drawWidth) / 2;
const drawY = y + (slotHeight - drawHeight) / 2;
ctx.save();
if (rotation !== 0) {
const centerX = drawX + drawWidth / 2;
const centerY = drawY + drawHeight / 2;
ctx.translate(centerX, centerY);
ctx.rotate((rotation * Math.PI) / 180);
ctx.drawImage(thumbnail, -drawHeight / 2, -drawWidth / 2, drawHeight, drawWidth);
} else {
ctx.drawImage(thumbnail, drawX, drawY, drawWidth, drawHeight);
}
ctx.restore();
ctx.strokeStyle = '#6b7280';
ctx.lineWidth = 1;
ctx.strokeRect(drawX, drawY, drawWidth, drawHeight);
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`${pageNumber}`, x + slotWidth / 2, y + slotHeight - 4);
}
} else {
ctx.fillStyle = '#374151';
ctx.fillRect(x, y, slotWidth, slotHeight);
ctx.strokeStyle = '#4b5563';
ctx.lineWidth = 1;
ctx.strokeRect(x, y, slotWidth, slotHeight);
ctx.fillStyle = '#6b7280';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('(blank)', x + slotWidth / 2, y + slotHeight / 2);
}
}
}
ctx.fillStyle = '#9ca3af';
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'top';
const sideLabel = isBookletMode ? (isFront ? 'Front' : 'Back') : '';
ctx.fillText(`Sheet ${Math.floor(sheetIndex / (isBookletMode ? 2 : 1)) + 1} ${sideLabel}`, canvasWidth - 6, 4);
previewArea.appendChild(canvas);
}
pageThumbnails.forEach(bitmap => bitmap.close());
const downloadBtn = document.getElementById('download-btn') as HTMLButtonElement;
downloadBtn.disabled = false;
}
function applyRotation(doc: PDFLibDocument, mode: string) {
const pages = doc.getPages();
pages.forEach((page, index) => {
let rotation = 0;
switch (mode) {
case '90cw': rotation = 90; break;
case '90ccw': rotation = -90; break;
case 'alternate': rotation = (index % 2 === 0) ? 90 : -90; break;
default: rotation = 0;
}
if (rotation !== 0) {
page.setRotation(degrees(page.getRotation().angle + rotation));
}
});
}
async function createBooklet() {
if (!pageState.pdfBytes) {
showAlert('Error', 'Please load a PDF first.');
return;
}
showLoader('Creating Booklet...');
try {
const sourceDoc = await PDFLibDocument.load(pageState.pdfBytes.slice());
const rotationMode = (document.querySelector('input[name="rotation"]:checked') as HTMLInputElement)?.value || 'none';
applyRotation(sourceDoc, rotationMode);
const totalPages = sourceDoc.getPageCount();
const { rows, cols } = getGridDimensions();
const pagesPerSheet = rows * cols;
const isBookletMode = rows === 1 && cols === 2;
const { width: sheetWidth, height: sheetHeight } = getSheetDimensions(isBookletMode);
const outputDoc = await PDFLibDocument.create();
let numSheets: number;
let totalRounded: number;
if (isBookletMode) {
totalRounded = Math.ceil(totalPages / 4) * 4;
numSheets = Math.ceil(totalPages / 4) * 2;
} else {
totalRounded = totalPages;
numSheets = Math.ceil(totalPages / pagesPerSheet);
}
const cellWidth = sheetWidth / cols;
const cellHeight = sheetHeight / rows;
const padding = 10;
for (let sheetIndex = 0; sheetIndex < numSheets; sheetIndex++) {
const outputPage = outputDoc.addPage([sheetWidth, sheetHeight]);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const slotIndex = r * cols + c;
let pageNumber: number;
if (isBookletMode) {
const physicalSheet = Math.floor(sheetIndex / 2);
const isFrontSide = sheetIndex % 2 === 0;
if (isFrontSide) {
pageNumber = c === 0 ? totalRounded - 2 * physicalSheet : 2 * physicalSheet + 1;
} else {
pageNumber = c === 0 ? 2 * physicalSheet + 2 : totalRounded - 2 * physicalSheet - 1;
}
} else {
pageNumber = sheetIndex * pagesPerSheet + slotIndex + 1;
}
if (pageNumber >= 1 && pageNumber <= totalPages) {
const [embeddedPage] = await outputDoc.embedPdf(sourceDoc, [pageNumber - 1]);
const { width: srcW, height: srcH } = embeddedPage;
const availableWidth = cellWidth - padding * 2;
const availableHeight = cellHeight - padding * 2;
const scale = Math.min(availableWidth / srcW, availableHeight / srcH);
const scaledWidth = srcW * scale;
const scaledHeight = srcH * scale;
const x = c * cellWidth + padding + (availableWidth - scaledWidth) / 2;
const y = sheetHeight - (r + 1) * cellHeight + padding + (availableHeight - scaledHeight) / 2;
outputPage.drawPage(embeddedPage, {
x,
y,
width: scaledWidth,
height: scaledHeight,
});
}
}
}
}
const pdfBytes = await outputDoc.save();
const originalName = pageState.file?.name.replace(/\.pdf$/i, '') || 'document';
downloadFile(
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
`${originalName}_booklet.pdf`
);
showAlert('Success', `Booklet created with ${numSheets} sheets!`, 'success', function () {
resetState();
});
} catch (e) {
console.error(e);
showAlert('Error', 'An error occurred while creating the booklet.');
} finally {
hideLoader();
}
}
function handleFileSelect(files: FileList | null) {
if (files && files.length > 0) {
const file = files[0];
if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
pageState.file = file;
updateUI();
}
}
}
document.addEventListener('DOMContentLoaded', function () {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const previewBtn = document.getElementById('preview-btn');
const downloadBtn = document.getElementById('download-btn');
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
backBtn.addEventListener('click', function () {
window.location.href = import.meta.env.BASE_URL;
});
}
if (fileInput && dropZone) {
fileInput.addEventListener('change', function (e) {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', function (e) {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', function (e) {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', function (e) {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const pdfFiles = Array.from(files).filter(function (f) {
return f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf');
});
if (pdfFiles.length > 0) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(pdfFiles[0]);
handleFileSelect(dataTransfer.files);
}
}
});
fileInput.addEventListener('click', function () {
fileInput.value = '';
});
}
if (previewBtn) {
previewBtn.addEventListener('click', generatePreview);
}
if (downloadBtn) {
downloadBtn.addEventListener('click', createBooklet);
}
});

View File

@@ -0,0 +1,416 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, readFileAsArrayBuffer, formatBytes, getPDFDocument } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
interface LayerData {
number: number;
xref: number;
text: string;
on: boolean;
locked: boolean;
depth: number;
parentXref: number;
displayOrder: number;
};
let currentFile: File | null = null;
let currentDoc: any = null;
const layersMap = new Map<number, LayerData>();
let nextDisplayOrder = 0;
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const processBtnContainer = document.getElementById('process-btn-container');
const fileDisplayArea = document.getElementById('file-display-area');
const layersContainer = document.getElementById('layers-container');
const layersList = document.getElementById('layers-list');
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!fileDisplayArea || !processBtnContainer || !processBtn) return;
if (currentFile) {
fileDisplayArea.innerHTML = '';
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = currentFile.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(currentFile.size)} • Loading pages...`;
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
resetState();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
try {
const arrayBuffer = await readFileAsArrayBuffer(currentFile);
const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
metaSpan.textContent = `${formatBytes(currentFile.size)}${pdfDoc.numPages} pages`;
} catch (error) {
console.error('Error loading PDF:', error);
metaSpan.textContent = `${formatBytes(currentFile.size)} • Could not load page count`;
}
createIcons({ icons });
processBtnContainer.classList.remove('hidden');
(processBtn as HTMLButtonElement).disabled = false;
} else {
fileDisplayArea.innerHTML = '';
processBtnContainer.classList.add('hidden');
(processBtn as HTMLButtonElement).disabled = true;
}
};
const resetState = () => {
currentFile = null;
currentDoc = null;
layersMap.clear();
nextDisplayOrder = 0;
if (dropZone) dropZone.style.display = 'flex';
if (layersContainer) layersContainer.classList.add('hidden');
updateUI();
};
const promptForInput = (title: string, message: string, defaultValue: string = ''): Promise<string | null> => {
return new Promise((resolve) => {
const modal = document.getElementById('input-modal');
const titleEl = document.getElementById('input-title');
const messageEl = document.getElementById('input-message');
const inputEl = document.getElementById('input-value') as HTMLInputElement;
const confirmBtn = document.getElementById('input-confirm');
const cancelBtn = document.getElementById('input-cancel');
if (!modal || !titleEl || !messageEl || !inputEl || !confirmBtn || !cancelBtn) {
console.error('Input modal elements not found');
resolve(null);
return;
}
titleEl.textContent = title;
messageEl.textContent = message;
inputEl.value = defaultValue;
const closeModal = () => {
modal.classList.add('hidden');
confirmBtn.onclick = null;
cancelBtn.onclick = null;
inputEl.onkeydown = null;
};
const confirm = () => {
const val = inputEl.value.trim();
closeModal();
resolve(val);
};
const cancel = () => {
closeModal();
resolve(null);
};
confirmBtn.onclick = confirm;
cancelBtn.onclick = cancel;
inputEl.onkeydown = (e) => {
if (e.key === 'Enter') confirm();
if (e.key === 'Escape') cancel();
};
modal.classList.remove('hidden');
inputEl.focus();
});
};
const renderLayers = () => {
if (!layersList) return;
const layersArray = Array.from(layersMap.values());
if (layersArray.length === 0) {
layersList.innerHTML = `
<div class="layers-empty">
<p>This PDF has no layers (OCG).</p>
<p>Add a new layer to get started!</p>
</div>
`;
return;
}
// Sort layers by displayOrder
const sortedLayers = layersArray.sort((a, b) => a.displayOrder - b.displayOrder);
layersList.innerHTML = sortedLayers.map((layer: LayerData) => `
<div class="layer-item" data-number="${layer.number}" style="padding-left: ${layer.depth * 24 + 8}px;">
<label class="layer-toggle">
<input type="checkbox" ${layer.on ? 'checked' : ''} ${layer.locked ? 'disabled' : ''} data-xref="${layer.xref}" />
<span class="layer-name">${layer.depth > 0 ? '└ ' : ''}${layer.text || `Layer ${layer.number}`}</span>
${layer.locked ? '<span class="layer-locked">🔒</span>' : ''}
</label>
<div class="layer-actions">
${!layer.locked ? `<button class="layer-add-child" data-xref="${layer.xref}" title="Add child layer">+</button>` : ''}
${!layer.locked ? `<button class="layer-delete" data-xref="${layer.xref}" title="Delete layer">✕</button>` : ''}
</div>
</div>
`).join('');
// Attach toggle handlers
layersList.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => {
checkbox.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement;
const xref = parseInt(target.dataset.xref || '0');
const isOn = target.checked;
try {
currentDoc.setLayerVisibility(xref, isOn);
const layer = Array.from(layersMap.values()).find(l => l.xref === xref);
if (layer) {
layer.on = isOn;
}
} catch (err) {
console.error('Failed to set layer visibility:', err);
target.checked = !isOn;
showAlert('Error', 'Failed to toggle layer visibility');
}
});
});
// Attach delete handlers
layersList.querySelectorAll('.layer-delete').forEach((btn) => {
btn.addEventListener('click', (e) => {
const target = e.target as HTMLButtonElement;
const xref = parseInt(target.dataset.xref || '0');
const layer = Array.from(layersMap.values()).find(l => l.xref === xref);
if (!layer) {
showAlert('Error', 'Layer not found');
return;
}
try {
currentDoc.deleteOCG(layer.number);
layersMap.delete(layer.number);
renderLayers();
} catch (err) {
console.error('Failed to delete layer:', err);
showAlert('Error', 'Failed to delete layer');
}
});
});
layersList.querySelectorAll('.layer-add-child').forEach((btn) => {
btn.addEventListener('click', async (e) => {
const target = e.target as HTMLButtonElement;
const parentXref = parseInt(target.dataset.xref || '0');
const parentLayer = Array.from(layersMap.values()).find(l => l.xref === parentXref);
const childName = await promptForInput('Add Child Layer', `Enter name for child layer under "${parentLayer?.text || 'Layer'}":`);
if (!childName || !childName.trim()) return;
try {
const childXref = currentDoc.addOCGWithParent(childName.trim(), parentXref);
const parentDisplayOrder = parentLayer?.displayOrder || 0;
layersMap.forEach((l) => {
if (l.displayOrder > parentDisplayOrder) {
l.displayOrder += 1;
}
});
layersMap.set(childXref, {
number: childXref,
xref: childXref,
text: childName.trim(),
on: true,
locked: false,
depth: (parentLayer?.depth || 0) + 1,
parentXref: parentXref,
displayOrder: parentDisplayOrder + 1
});
renderLayers();
} catch (err) {
console.error('Failed to add child layer:', err);
showAlert('Error', 'Failed to add child layer');
}
});
});
};
const loadLayers = async () => {
if (!currentFile) {
showAlert('No File', 'Please select a PDF file.');
return;
}
try {
showLoader('Loading engine...');
await pymupdf.load();
showLoader(`Loading layers from ${currentFile.name}...`);
currentDoc = await pymupdf.open(currentFile);
showLoader('Reading layer configuration...');
const existingLayers = currentDoc.getLayerConfig();
// Reset and populate layers map
layersMap.clear();
nextDisplayOrder = 0;
existingLayers.forEach((layer: any) => {
layersMap.set(layer.number, {
number: layer.number,
xref: layer.xref ?? layer.number,
text: layer.text,
on: layer.on,
locked: layer.locked,
depth: layer.depth ?? 0,
parentXref: layer.parentXref ?? 0,
displayOrder: layer.displayOrder ?? nextDisplayOrder++
});
if ((layer.displayOrder ?? -1) >= nextDisplayOrder) {
nextDisplayOrder = layer.displayOrder + 1;
}
});
hideLoader();
// Hide upload zone, show layers container
if (dropZone) dropZone.style.display = 'none';
if (processBtnContainer) processBtnContainer.classList.add('hidden');
if (layersContainer) layersContainer.classList.remove('hidden');
renderLayers();
setupLayerHandlers();
} catch (error: any) {
hideLoader();
showAlert('Error', error.message || 'Failed to load PDF layers');
console.error('Layers error:', error);
}
};
const setupLayerHandlers = () => {
const addLayerBtn = document.getElementById('add-layer-btn');
const newLayerInput = document.getElementById('new-layer-name') as HTMLInputElement;
const saveLayersBtn = document.getElementById('save-layers-btn');
if (addLayerBtn && newLayerInput) {
addLayerBtn.onclick = () => {
const name = newLayerInput.value.trim();
if (!name) {
showAlert('Invalid Name', 'Please enter a layer name');
return;
}
try {
const xref = currentDoc.addOCG(name);
newLayerInput.value = '';
const newDisplayOrder = nextDisplayOrder++;
layersMap.set(xref, {
number: xref,
xref: xref,
text: name,
on: true,
locked: false,
depth: 0,
parentXref: 0,
displayOrder: newDisplayOrder
});
renderLayers();
} catch (err: any) {
showAlert('Error', 'Failed to add layer: ' + err.message);
}
};
}
if (saveLayersBtn) {
saveLayersBtn.onclick = () => {
try {
showLoader('Saving PDF with layer changes...');
const pdfBytes = currentDoc.save();
const blob = new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' });
const outName = currentFile!.name.replace(/\.pdf$/i, '') + '_layers.pdf';
downloadFile(blob, outName);
hideLoader();
resetState();
showAlert('Success', 'PDF with layer changes saved!', 'success');
} catch (err: any) {
hideLoader();
showAlert('Error', 'Failed to save PDF: ' + err.message);
}
};
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const file = files[0];
if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
currentFile = file;
updateUI();
} else {
showAlert('Invalid File', 'Please select a PDF file.');
}
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (processBtn) {
processBtn.addEventListener('click', loadLayers);
}
});

View File

@@ -0,0 +1,172 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
import JSZip from 'jszip';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
let file: File | null = null;
const updateUI = () => {
const fileDisplayArea = document.getElementById('file-display-area');
const optionsPanel = document.getElementById('options-panel');
if (!fileDisplayArea || !optionsPanel) return;
fileDisplayArea.innerHTML = '';
if (file) {
optionsPanel.classList.remove('hidden');
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = resetState;
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
createIcons({ icons });
} else {
optionsPanel.classList.add('hidden');
}
};
const resetState = () => {
file = null;
const fileInput = document.getElementById('file-input') as HTMLInputElement;
if (fileInput) fileInput.value = '';
updateUI();
};
function tableToCsv(rows: (string | null)[][]): string {
return rows.map(row =>
row.map(cell => {
const cellStr = cell ?? '';
if (cellStr.includes(',') || cellStr.includes('"') || cellStr.includes('\n')) {
return `"${cellStr.replace(/"/g, '""')}"`;
}
return cellStr;
}).join(',')
).join('\n');
}
async function convert() {
if (!file) {
showAlert('No File', 'Please upload a PDF file first.');
return;
}
showLoader('Loading Engine...');
try {
await pymupdf.load();
showLoader('Extracting tables...');
const doc = await pymupdf.open(file);
const pageCount = doc.pageCount;
const baseName = file.name.replace(/\.[^/.]+$/, '');
const allRows: (string | null)[][] = [];
for (let i = 0; i < pageCount; i++) {
showLoader(`Scanning page ${i + 1} of ${pageCount}...`);
const page = doc.getPage(i);
const tables = page.findTables();
tables.forEach((table) => {
allRows.push(...table.rows);
allRows.push([]);
});
}
if (allRows.length === 0) {
showAlert('No Tables Found', 'No tables were detected in this PDF.');
return;
}
const csvContent = tableToCsv(allRows.filter(row => row.length > 0));
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
downloadFile(blob, `${baseName}.csv`);
showAlert('Success', 'PDF converted to CSV successfully!', 'success', resetState);
} catch (e) {
console.error(e);
const message = e instanceof Error ? e.message : 'Unknown error';
showAlert('Error', `Failed to convert PDF to CSV. ${message}`);
} finally {
hideLoader();
}
}
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const handleFileSelect = (newFiles: FileList | null) => {
if (!newFiles || newFiles.length === 0) return;
const validFile = Array.from(newFiles).find(f => f.type === 'application/pdf');
if (!validFile) {
showAlert('Invalid File', 'Please upload a PDF file.');
return;
}
file = validFile;
updateUI();
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (processBtn) {
processBtn.addEventListener('click', convert);
}
});

View File

@@ -0,0 +1,203 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, readFileAsArrayBuffer, formatBytes, getPDFDocument } from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const fileDisplayArea = document.getElementById('file-display-area');
const convertOptions = document.getElementById('convert-options');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!fileDisplayArea || !convertOptions || !processBtn || !fileControls) return;
if (state.files.length > 0) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`;
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_: File, i: number) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
metaSpan.textContent = `${formatBytes(file.size)}${pdfDoc.numPages} pages`;
} catch (error) {
metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`;
}
}
createIcons({ icons });
fileControls.classList.remove('hidden');
convertOptions.classList.remove('hidden');
(processBtn as HTMLButtonElement).disabled = false;
} else {
fileDisplayArea.innerHTML = '';
fileControls.classList.add('hidden');
convertOptions.classList.add('hidden');
(processBtn as HTMLButtonElement).disabled = true;
}
};
const resetState = () => {
state.files = [];
state.pdfDoc = null;
updateUI();
};
const convert = async () => {
try {
if (state.files.length === 0) {
showAlert('No Files', 'Please select at least one PDF file.');
return;
}
showLoader('Loading PDF converter...');
await pymupdf.load();
if (state.files.length === 1) {
const file = state.files[0];
showLoader(`Converting ${file.name}...`);
const docxBlob = await pymupdf.pdfToDocx(file);
const outName = file.name.replace(/\.pdf$/i, '') + '.docx';
downloadFile(docxBlob, outName);
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${file.name} to DOCX.`,
'success',
() => resetState()
);
} else {
showLoader('Converting multiple PDFs...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const docxBlob = await pymupdf.pdfToDocx(file);
const baseName = file.name.replace(/\.pdf$/i, '');
const arrayBuffer = await docxBlob.arrayBuffer();
zip.file(`${baseName}.docx`, arrayBuffer);
}
showLoader('Creating ZIP archive...');
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'converted-documents.zip');
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} PDF(s) to DOCX.`,
'success',
() => resetState()
);
}
} catch (e: any) {
hideLoader();
showAlert('Error', `An error occurred during conversion. Error: ${e.message}`);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const pdfFiles = Array.from(files).filter(
f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')
);
state.files = [...state.files, ...pdfFiles];
updateUI();
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
handleFileSelect(files);
}
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput.click();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', () => {
resetState();
});
}
if (processBtn) {
processBtn.addEventListener('click', convert);
}
});

View File

@@ -0,0 +1,182 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
import * as XLSX from 'xlsx';
const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
let file: File | null = null;
const updateUI = () => {
const fileDisplayArea = document.getElementById('file-display-area');
const optionsPanel = document.getElementById('options-panel');
if (!fileDisplayArea || !optionsPanel) return;
fileDisplayArea.innerHTML = '';
if (file) {
optionsPanel.classList.remove('hidden');
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = resetState;
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
createIcons({ icons });
} else {
optionsPanel.classList.add('hidden');
}
};
const resetState = () => {
file = null;
const fileInput = document.getElementById('file-input') as HTMLInputElement;
if (fileInput) fileInput.value = '';
updateUI();
};
async function convert() {
if (!file) {
showAlert('No File', 'Please upload a PDF file first.');
return;
}
showLoader('Loading Engine...');
try {
await pymupdf.load();
showLoader('Extracting tables...');
const doc = await pymupdf.open(file);
const pageCount = doc.pageCount;
const baseName = file.name.replace(/\.[^/.]+$/, '');
interface TableData {
page: number;
rows: (string | null)[][];
}
const allTables: TableData[] = [];
for (let i = 0; i < pageCount; i++) {
showLoader(`Scanning page ${i + 1} of ${pageCount}...`);
const page = doc.getPage(i);
const tables = page.findTables();
tables.forEach((table) => {
allTables.push({
page: i + 1,
rows: table.rows
});
});
}
if (allTables.length === 0) {
showAlert('No Tables Found', 'No tables were detected in this PDF.');
return;
}
showLoader('Creating Excel file...');
const workbook = XLSX.utils.book_new();
if (allTables.length === 1) {
const worksheet = XLSX.utils.aoa_to_sheet(allTables[0].rows);
XLSX.utils.book_append_sheet(workbook, worksheet, 'Table');
} else {
allTables.forEach((table, idx) => {
const sheetName = `Table ${idx + 1} (Page ${table.page})`.substring(0, 31);
const worksheet = XLSX.utils.aoa_to_sheet(table.rows);
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
});
}
const xlsxData = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
const blob = new Blob([xlsxData], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
downloadFile(blob, `${baseName}.xlsx`);
showAlert('Success', `Extracted ${allTables.length} table(s) to Excel!`, 'success', resetState);
} catch (e) {
console.error(e);
const message = e instanceof Error ? e.message : 'Unknown error';
showAlert('Error', `Failed to convert PDF to Excel. ${message}`);
} finally {
hideLoader();
}
}
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const handleFileSelect = (newFiles: FileList | null) => {
if (!newFiles || newFiles.length === 0) return;
const validFile = Array.from(newFiles).find(f => f.type === 'application/pdf');
if (!validFile) {
showAlert('Invalid File', 'Please upload a PDF file.');
return;
}
file = validFile;
updateUI();
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (processBtn) {
processBtn.addEventListener('click', convert);
}
});

View File

@@ -0,0 +1,206 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, readFileAsArrayBuffer, formatBytes, getPDFDocument } from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const fileDisplayArea = document.getElementById('file-display-area');
const convertOptions = document.getElementById('convert-options');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const includeImagesCheckbox = document.getElementById('include-images') as HTMLInputElement;
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!fileDisplayArea || !convertOptions || !processBtn || !fileControls) return;
if (state.files.length > 0) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`;
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_: File, i: number) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
metaSpan.textContent = `${formatBytes(file.size)}${pdfDoc.numPages} pages`;
} catch (error) {
metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`;
}
}
createIcons({ icons });
fileControls.classList.remove('hidden');
convertOptions.classList.remove('hidden');
(processBtn as HTMLButtonElement).disabled = false;
} else {
fileDisplayArea.innerHTML = '';
fileControls.classList.add('hidden');
convertOptions.classList.add('hidden');
(processBtn as HTMLButtonElement).disabled = true;
}
};
const resetState = () => {
state.files = [];
state.pdfDoc = null;
updateUI();
};
const convert = async () => {
try {
if (state.files.length === 0) {
showAlert('No Files', 'Please select at least one PDF file.');
return;
}
showLoader('Loading PDF converter...');
await pymupdf.load();
const includeImages = includeImagesCheckbox?.checked ?? false;
if (state.files.length === 1) {
const file = state.files[0];
showLoader(`Converting ${file.name}...`);
const markdown = await pymupdf.pdfToMarkdown(file, { includeImages });
const outName = file.name.replace(/\.pdf$/i, '') + '.md';
const blob = new Blob([markdown], { type: 'text/markdown' });
downloadFile(blob, outName);
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${file.name} to Markdown.`,
'success',
() => resetState()
);
} else {
showLoader('Converting multiple PDFs...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const markdown = await pymupdf.pdfToMarkdown(file, { includeImages });
const baseName = file.name.replace(/\.pdf$/i, '');
zip.file(`${baseName}.md`, markdown);
}
showLoader('Creating ZIP archive...');
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'markdown-files.zip');
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} PDF(s) to Markdown.`,
'success',
() => resetState()
);
}
} catch (e: any) {
hideLoader();
showAlert('Error', `An error occurred during conversion. Error: ${e.message}`);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const pdfFiles = Array.from(files).filter(
f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')
);
state.files = [...state.files, ...pdfFiles];
updateUI();
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
handleFileSelect(files);
}
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput.click();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', () => {
resetState();
});
}
if (processBtn) {
processBtn.addEventListener('click', convert);
}
});

View File

@@ -0,0 +1,228 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import {
downloadFile,
readFileAsArrayBuffer,
formatBytes,
getPDFDocument,
} from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { convertFileToPdfA, type PdfALevel } from '../utils/ghostscript-loader';
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const fileDisplayArea = document.getElementById('file-display-area');
const optionsContainer = document.getElementById('options-container');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const pdfaLevelSelect = document.getElementById('pdfa-level') as HTMLSelectElement;
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!fileDisplayArea || !optionsContainer || !processBtn || !fileControls) return;
if (state.files.length > 0) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`;
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
metaSpan.textContent = `${formatBytes(file.size)}${pdfDoc.numPages} pages`;
} catch (error) {
console.error('Error loading PDF:', error);
metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`;
}
}
createIcons({ icons });
fileControls.classList.remove('hidden');
optionsContainer.classList.remove('hidden');
(processBtn as HTMLButtonElement).disabled = false;
} else {
fileDisplayArea.innerHTML = '';
fileControls.classList.add('hidden');
optionsContainer.classList.add('hidden');
(processBtn as HTMLButtonElement).disabled = true;
}
};
const resetState = () => {
state.files = [];
state.pdfDoc = null;
if (pdfaLevelSelect) pdfaLevelSelect.value = 'PDF/A-2b';
updateUI();
};
const convertToPdfA = async () => {
const level = pdfaLevelSelect.value as PdfALevel;
try {
if (state.files.length === 0) {
showAlert('No Files', 'Please select at least one PDF file.');
hideLoader();
return;
}
if (state.files.length === 1) {
const originalFile = state.files[0];
showLoader('Initializing Ghostscript...');
const convertedBlob = await convertFileToPdfA(
originalFile,
level,
(msg) => showLoader(msg)
);
const fileName = originalFile.name.replace(/\.pdf$/i, '') + '_pdfa.pdf';
downloadFile(convertedBlob, fileName);
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${originalFile.name} to ${level}.`,
'success',
() => resetState()
);
} else {
showLoader('Converting multiple PDFs to PDF/A...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const convertedBlob = await convertFileToPdfA(
file,
level,
(msg) => showLoader(msg)
);
const baseName = file.name.replace(/\.pdf$/i, '');
const blobBuffer = await convertedBlob.arrayBuffer();
zip.file(`${baseName}_pdfa.pdf`, blobBuffer);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'pdfa-converted.zip');
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} PDF(s) to ${level}.`,
'success',
() => resetState()
);
}
} catch (e: any) {
hideLoader();
showAlert(
'Error',
`An error occurred during conversion. Error: ${e.message}`
);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
state.files = [...state.files, ...Array.from(files)];
updateUI();
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const pdfFiles = Array.from(files).filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
if (pdfFiles.length > 0) {
const dataTransfer = new DataTransfer();
pdfFiles.forEach(f => dataTransfer.items.add(f));
handleFileSelect(dataTransfer.files);
}
}
});
// Clear value on click to allow re-selecting the same file
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput.click();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', () => {
resetState();
});
}
if (processBtn) {
processBtn.addEventListener('click', convertToPdfA);
}
});

View File

@@ -0,0 +1,202 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
import JSZip from 'jszip';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
let files: File[] = [];
const updateUI = () => {
const fileDisplayArea = document.getElementById('file-display-area');
const optionsPanel = document.getElementById('options-panel');
const fileControls = document.getElementById('file-controls');
if (!fileDisplayArea || !optionsPanel) return;
fileDisplayArea.innerHTML = '';
if (files.length > 0) {
optionsPanel.classList.remove('hidden');
if (fileControls) fileControls.classList.remove('hidden');
files.forEach((file, index) => {
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
files = files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
});
createIcons({ icons });
} else {
optionsPanel.classList.add('hidden');
if (fileControls) fileControls.classList.add('hidden');
}
};
const resetState = () => {
files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
if (fileInput) fileInput.value = '';
updateUI();
};
async function convert() {
if (files.length === 0) {
showAlert('No Files', 'Please upload at least one PDF file.');
return;
}
showLoader('Loading Engine...');
try {
await pymupdf.load();
const isSingleFile = files.length === 1;
if (isSingleFile) {
const doc = await pymupdf.open(files[0]);
const pageCount = doc.pageCount;
const baseName = files[0].name.replace(/\.[^/.]+$/, '');
if (pageCount === 1) {
showLoader('Converting to SVG...');
const page = doc.getPage(0);
const svgContent = page.toSvg();
const svgBlob = new Blob([svgContent], { type: 'image/svg+xml' });
downloadFile(svgBlob, `${baseName}.svg`);
showAlert('Success', 'PDF converted to SVG successfully!', 'success', () => resetState());
} else {
const zip = new JSZip();
for (let i = 0; i < pageCount; i++) {
showLoader(`Converting page ${i + 1} of ${pageCount}...`);
const page = doc.getPage(i);
const svgContent = page.toSvg();
zip.file(`page_${i + 1}.svg`, svgContent);
}
showLoader('Creating ZIP file...');
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${baseName}_svg.zip`);
showAlert('Success', `Converted ${pageCount} pages to SVG!`, 'success', () => resetState());
}
} else {
const zip = new JSZip();
let totalPages = 0;
for (let f = 0; f < files.length; f++) {
const file = files[f];
showLoader(`Processing file ${f + 1} of ${files.length}...`);
const doc = await pymupdf.open(file);
const pageCount = doc.pageCount;
const baseName = file.name.replace(/\.[^/.]+$/, '');
for (let i = 0; i < pageCount; i++) {
showLoader(`File ${f + 1}/${files.length}: Page ${i + 1}/${pageCount}`);
const page = doc.getPage(i);
const svgContent = page.toSvg();
const fileName = pageCount === 1 ? `${baseName}.svg` : `${baseName}_page_${i + 1}.svg`;
zip.file(fileName, svgContent);
totalPages++;
}
}
showLoader('Creating ZIP file...');
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'pdf_to_svg.zip');
showAlert('Success', `Converted ${files.length} files (${totalPages} pages) to SVG!`, 'success', () => resetState());
}
} catch (e) {
console.error(e);
const message = e instanceof Error ? e.message : 'Unknown error';
showAlert('Error', `Failed to convert PDF to SVG. ${message}`);
} finally {
hideLoader();
}
}
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const backBtn = document.getElementById('back-to-tools');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const handleFileSelect = (newFiles: FileList | null, replace = false) => {
if (!newFiles || newFiles.length === 0) return;
const validFiles = Array.from(newFiles).filter(
(file) => file.type === 'application/pdf'
);
if (validFiles.length === 0) {
showAlert('Invalid Files', 'Please upload PDF files.');
return;
}
if (replace) {
files = validFiles;
} else {
files = [...files, ...validFiles];
}
updateUI();
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files, files.length === 0);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null, files.length === 0);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) addMoreBtn.addEventListener('click', () => fileInput?.click());
if (clearFilesBtn) clearFilesBtn.addEventListener('click', resetState);
if (processBtn) processBtn.addEventListener('click', convert);
});

View File

@@ -0,0 +1,212 @@
import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
let files: File[] = [];
let pymupdf: PyMuPDF | null = null;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
function initializePage() {
createIcons({ icons });
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const processBtn = document.getElementById('process-btn') as HTMLButtonElement;
if (fileInput) {
fileInput.addEventListener('change', handleFileUpload);
}
if (dropZone) {
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-600');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('bg-gray-600');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-600');
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
handleFiles(droppedFiles);
}
});
fileInput?.addEventListener('click', () => {
if (fileInput) fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput?.click();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', () => {
files = [];
updateUI();
});
}
if (processBtn) {
processBtn.addEventListener('click', extractText);
}
document.getElementById('back-to-tools')?.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
function handleFileUpload(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handleFiles(input.files);
}
}
function handleFiles(newFiles: FileList) {
const validFiles = Array.from(newFiles).filter(file =>
file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')
);
if (validFiles.length < newFiles.length) {
showAlert('Invalid Files', 'Some files were skipped. Only PDF files are allowed.');
}
if (validFiles.length > 0) {
files = [...files, ...validFiles];
updateUI();
}
}
const resetState = () => {
files = [];
updateUI();
};
function updateUI() {
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const extractOptions = document.getElementById('extract-options');
if (!fileDisplayArea || !fileControls || !extractOptions) return;
fileDisplayArea.innerHTML = '';
if (files.length > 0) {
fileControls.classList.remove('hidden');
extractOptions.classList.remove('hidden');
files.forEach((file, index) => {
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex items-center gap-2 overflow-hidden';
const nameSpan = document.createElement('span');
nameSpan.className = 'truncate font-medium text-gray-200';
nameSpan.textContent = file.name;
const sizeSpan = document.createElement('span');
sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs';
sizeSpan.textContent = `(${formatBytes(file.size)})`;
infoContainer.append(nameSpan, sizeSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
files = files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
});
createIcons({ icons });
} else {
fileControls.classList.add('hidden');
extractOptions.classList.add('hidden');
}
}
async function ensurePyMuPDF(): Promise<PyMuPDF> {
if (!pymupdf) {
pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
await pymupdf.load();
}
return pymupdf;
}
async function extractText() {
if (files.length === 0) {
showAlert('No Files', 'Please select at least one PDF file.');
return;
}
showLoader('Loading engine...');
try {
const mupdf = await ensurePyMuPDF();
if (files.length === 1) {
const file = files[0];
showLoader(`Extracting text from ${file.name}...`);
const fullText = await mupdf.pdfToText(file);
const baseName = file.name.replace(/\.pdf$/i, '');
const textBlob = new Blob([fullText], { type: 'text/plain;charset=utf-8' });
downloadFile(textBlob, `${baseName}.txt`);
hideLoader();
showAlert('Success', 'Text extracted successfully!', 'success', () => {
resetState();
});
} else {
showLoader('Extracting text from multiple files...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < files.length; i++) {
const file = files[i];
showLoader(`Extracting text from file ${i + 1}/${files.length}: ${file.name}...`);
const fullText = await mupdf.pdfToText(file);
const baseName = file.name.replace(/\.pdf$/i, '');
zip.file(`${baseName}.txt`, fullText);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'pdf-to-text.zip');
hideLoader();
showAlert('Success', `Extracted text from ${files.length} PDF files!`, 'success', () => {
resetState();
});
}
} catch (e: any) {
console.error('[PDFToText]', e);
hideLoader();
showAlert('Extraction Error', e.message || 'Failed to extract text from PDF.');
}
}

View File

@@ -3,17 +3,10 @@ import { downloadFile, parsePageRanges, getPDFDocument, formatBytes } from '../u
import { PDFDocument, PageSizes } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
import { createIcons, icons } from 'lucide';
import { PosterizeState } from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
interface PosterizeState {
file: File | null;
pdfJsDoc: pdfjsLib.PDFDocumentProxy | null;
pdfBytes: Uint8Array | null;
pageSnapshots: Record<number, ImageData>;
currentPage: number;
}
const pageState: PosterizeState = {
file: null,
pdfJsDoc: null,
@@ -143,7 +136,7 @@ async function posterize() {
const rows = parseInt((document.getElementById('posterize-rows') as HTMLInputElement).value) || 1;
const cols = parseInt((document.getElementById('posterize-cols') as HTMLInputElement).value) || 1;
const pageSizeKey = (document.getElementById('output-page-size') as HTMLSelectElement).value as keyof typeof PageSizes;
let orientation = (document.getElementById('output-orientation') as HTMLSelectElement).value;
const orientation = (document.getElementById('output-orientation') as HTMLSelectElement).value;
const scalingMode = (document.querySelector('input[name="scaling-mode"]:checked') as HTMLInputElement).value;
const overlap = parseFloat((document.getElementById('overlap') as HTMLInputElement).value) || 0;
const overlapUnits = (document.getElementById('overlap-units') as HTMLSelectElement).value;

View File

@@ -0,0 +1,218 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import {
downloadFile,
readFileAsArrayBuffer,
formatBytes,
} from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { getLibreOfficeConverter, type LoadProgress } from '../utils/libreoffice-loader.js';
document.addEventListener('DOMContentLoaded', () => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const convertOptions = document.getElementById('convert-options');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const processBtn = document.getElementById('process-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!convertOptions) return;
if (state.files.length > 0) {
if (fileDisplayArea) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
}
if (fileControls) fileControls.classList.remove('hidden');
convertOptions.classList.remove('hidden');
} else {
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
if (fileControls) fileControls.classList.add('hidden');
convertOptions.classList.add('hidden');
}
};
const resetState = () => {
state.files = [];
state.pdfDoc = null;
updateUI();
};
const convertToPdf = async () => {
try {
if (state.files.length === 0) {
showAlert('No Files', 'Please select at least one PowerPoint file.');
hideLoader();
return;
}
const converter = getLibreOfficeConverter();
// Initialize LibreOffice if not already done
await converter.initialize((progress: LoadProgress) => {
showLoader(progress.message, progress.percent);
});
if (state.files.length === 1) {
const originalFile = state.files[0];
showLoader('Processing...');
const pdfBlob = await converter.convertToPdf(originalFile);
const fileName = originalFile.name.replace(/\.(ppt|pptx|odp)$/i, '') + '.pdf';
downloadFile(pdfBlob, fileName);
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${originalFile.name} to PDF.`,
'success',
() => resetState()
);
} else {
showLoader('Processing...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.(ppt|pptx|odp)$/i, '');
const pdfBuffer = await pdfBlob.arrayBuffer();
zip.file(`${baseName}.pdf`, pdfBuffer);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'powerpoint-converted.zip');
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} PowerPoint file(s) to PDF.`,
'success',
() => resetState()
);
}
} catch (e: any) {
hideLoader();
showAlert(
'Error',
`An error occurred during conversion. Error: ${e.message}`
);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
state.files = [...state.files, ...Array.from(files)];
updateUI();
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const pptFiles = Array.from(files).filter(f => {
const name = f.name.toLowerCase();
return name.endsWith('.ppt') || name.endsWith('.pptx') || name.endsWith('.odp');
});
if (pptFiles.length > 0) {
const dataTransfer = new DataTransfer();
pptFiles.forEach(f => dataTransfer.items.add(f));
handleFileSelect(dataTransfer.files);
}
}
});
// Clear value on click to allow re-selecting the same file
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput.click();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', () => {
resetState();
});
}
if (processBtn) {
processBtn.addEventListener('click', convertToPdf);
}
updateUI();
});

View File

@@ -0,0 +1,204 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, readFileAsArrayBuffer, formatBytes, getPDFDocument } from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const fileDisplayArea = document.getElementById('file-display-area');
const extractOptions = document.getElementById('extract-options');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!fileDisplayArea || !extractOptions || !processBtn || !fileControls) return;
if (state.files.length > 0) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`;
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
metaSpan.textContent = `${formatBytes(file.size)}${pdfDoc.numPages} pages`;
} catch (error) {
console.error('Error loading PDF:', error);
metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`;
}
}
createIcons({ icons });
fileControls.classList.remove('hidden');
extractOptions.classList.remove('hidden');
(processBtn as HTMLButtonElement).disabled = false;
} else {
fileDisplayArea.innerHTML = '';
fileControls.classList.add('hidden');
extractOptions.classList.add('hidden');
(processBtn as HTMLButtonElement).disabled = true;
}
};
const resetState = () => {
state.files = [];
state.pdfDoc = null;
updateUI();
};
const extractForAI = async () => {
try {
if (state.files.length === 0) {
showAlert('No Files', 'Please select at least one PDF file.');
return;
}
showLoader('Loading engine...');
await pymupdf.load();
const total = state.files.length;
let completed = 0;
let failed = 0;
if (total === 1) {
const file = state.files[0];
showLoader(`Extracting ${file.name} for AI...`);
const llamaDocs = await (pymupdf as any).pdfToLlamaIndex(file);
const outName = file.name.replace(/\.pdf$/i, '') + '_llm.json';
const jsonContent = JSON.stringify(llamaDocs, null, 2);
downloadFile(new Blob([jsonContent], { type: 'application/json' }), outName);
hideLoader();
showAlert('Extraction Complete', `Successfully extracted PDF for AI/LLM use.`, 'success', () => resetState());
} else {
// Multiple files - create ZIP
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (const file of state.files) {
try {
showLoader(`Extracting ${file.name} for AI (${completed + 1}/${total})...`);
const llamaDocs = await (pymupdf as any).pdfToLlamaIndex(file);
const outName = file.name.replace(/\.pdf$/i, '') + '_llm.json';
const jsonContent = JSON.stringify(llamaDocs, null, 2);
zip.file(outName, jsonContent);
completed++;
} catch (error) {
console.error(`Failed to extract ${file.name}:`, error);
failed++;
}
}
showLoader('Creating ZIP archive...');
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'pdf-for-ai.zip');
hideLoader();
if (failed === 0) {
showAlert('Extraction Complete', `Successfully extracted ${completed} PDF(s) for AI/LLM use.`, 'success', () => resetState());
} else {
showAlert('Extraction Partial', `Extracted ${completed} PDF(s), failed ${failed}.`, 'warning', () => resetState());
}
}
} catch (e: any) {
hideLoader();
showAlert('Error', `An error occurred during extraction. Error: ${e.message}`);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const pdfFiles = Array.from(files).filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
if (pdfFiles.length > 0) {
state.files = [...state.files, ...pdfFiles];
updateUI();
}
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput.click();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', resetState);
}
if (processBtn) {
processBtn.addEventListener('click', extractForAI);
}
});

View File

@@ -0,0 +1,134 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
const ACCEPTED_EXTENSIONS = ['.psd'];
const FILETYPE_NAME = 'PSD';
let pymupdf: PyMuPDF | null = null;
async function ensurePyMuPDF(): Promise<PyMuPDF> {
if (!pymupdf) {
pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
await pymupdf.load();
}
return pymupdf;
}
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!fileDisplayArea || !processBtn || !fileControls) return;
if (state.files.length > 0) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
fileControls.classList.remove('hidden');
processBtn.classList.remove('hidden');
} else {
fileDisplayArea.innerHTML = '';
fileControls.classList.add('hidden');
processBtn.classList.add('hidden');
}
};
const resetState = () => {
state.files = [];
updateUI();
};
const convert = async () => {
if (state.files.length === 0) {
showAlert('No Files', `Please select at least one ${FILETYPE_NAME} file.`);
return;
}
try {
showLoader('Loading engine...');
const mupdf = await ensurePyMuPDF();
if (state.files.length === 1) {
const file = state.files[0];
showLoader(`Converting ${file.name}...`);
const pdfBlob = await mupdf.imageToPdf(file, { imageType: 'psd' });
const baseName = file.name.replace(/\.[^/.]+$/, '');
downloadFile(pdfBlob, `${baseName}.pdf`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${file.name} to PDF.`, 'success', () => resetState());
} else {
showLoader('Converting multiple files...');
const pdfBlob = await mupdf.imagesToPdf(state.files);
downloadFile(pdfBlob, 'psd_to_pdf.pdf');
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${state.files.length} PSD files to a single PDF.`, 'success', () => resetState());
}
} catch (err) {
hideLoader();
const message = err instanceof Error ? err.message : 'Unknown error';
console.error(`[${FILETYPE_NAME}ToPDF] Error:`, err);
showAlert('Error', `An error occurred during conversion. Error: ${message}`);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const validFiles = Array.from(files).filter(file => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
return ACCEPTED_EXTENSIONS.includes(ext);
});
if (validFiles.length > 0) {
state.files = [...state.files, ...validFiles];
updateUI();
}
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => handleFileSelect((e.target as HTMLInputElement).files));
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('bg-gray-700'); });
dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); dropZone.classList.remove('bg-gray-700'); });
dropZone.addEventListener('drop', (e) => { e.preventDefault(); dropZone.classList.remove('bg-gray-700'); handleFileSelect(e.dataTransfer?.files ?? null); });
fileInput.addEventListener('click', () => { fileInput.value = ''; });
}
if (addMoreBtn) addMoreBtn.addEventListener('click', () => fileInput.click());
if (clearFilesBtn) clearFilesBtn.addEventListener('click', resetState);
if (processBtn) processBtn.addEventListener('click', convert);
});

View File

@@ -0,0 +1,142 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { getLibreOfficeConverter, type LoadProgress } from '../utils/libreoffice-loader.js';
const ACCEPTED_EXTENSIONS = ['.pub'];
const FILETYPE_NAME = 'PUB';
document.addEventListener('DOMContentLoaded', () => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const convertOptions = document.getElementById('convert-options');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const processBtn = document.getElementById('process-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!convertOptions) return;
if (state.files.length > 0) {
if (fileDisplayArea) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
}
if (fileControls) fileControls.classList.remove('hidden');
convertOptions.classList.remove('hidden');
} else {
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
if (fileControls) fileControls.classList.add('hidden');
convertOptions.classList.add('hidden');
}
};
const resetState = () => {
state.files = [];
updateUI();
};
const convert = async () => {
if (state.files.length === 0) {
showAlert('No Files', `Please select at least one ${FILETYPE_NAME} file.`);
return;
}
try {
const converter = getLibreOfficeConverter();
showLoader('Loading engine...');
await converter.initialize((progress: LoadProgress) => {
showLoader(progress.message, progress.percent);
});
if (state.files.length === 1) {
const file = state.files[0];
showLoader(`Converting ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
downloadFile(pdfBlob, `${baseName}.pdf`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${file.name} to PDF.`, 'success', () => resetState());
} else {
showLoader('Converting multiple files...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
zip.file(`${baseName}.pdf`, pdfBlob);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${FILETYPE_NAME.toLowerCase()}-to-pdf.zip`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${state.files.length} files to PDF.`, 'success', () => resetState());
}
} catch (err) {
hideLoader();
const message = err instanceof Error ? err.message : 'Unknown error';
console.error(`[${FILETYPE_NAME}ToPDF] Error:`, err);
showAlert('Error', `An error occurred during conversion. Error: ${message}`);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const validFiles = Array.from(files).filter(file => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
return ACCEPTED_EXTENSIONS.includes(ext);
});
if (validFiles.length > 0) {
state.files = [...state.files, ...validFiles];
updateUI();
}
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => handleFileSelect((e.target as HTMLInputElement).files));
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('bg-gray-700'); });
dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); dropZone.classList.remove('bg-gray-700'); });
dropZone.addEventListener('drop', (e) => { e.preventDefault(); dropZone.classList.remove('bg-gray-700'); handleFileSelect(e.dataTransfer?.files ?? null); });
fileInput.addEventListener('click', () => { fileInput.value = ''; });
}
if (addMoreBtn) addMoreBtn.addEventListener('click', () => fileInput.click());
if (clearFilesBtn) clearFilesBtn.addEventListener('click', resetState);
if (processBtn) processBtn.addEventListener('click', convert);
updateUI();
});

View File

@@ -0,0 +1,219 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, readFileAsArrayBuffer, formatBytes, getPDFDocument } from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const fileDisplayArea = document.getElementById('file-display-area');
const rasterizeOptions = document.getElementById('rasterize-options');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!fileDisplayArea || !rasterizeOptions || !processBtn || !fileControls) return;
if (state.files.length > 0) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`;
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
metaSpan.textContent = `${formatBytes(file.size)}${pdfDoc.numPages} pages`;
} catch (error) {
console.error('Error loading PDF:', error);
metaSpan.textContent = `${formatBytes(file.size)} • Could not load page count`;
}
}
createIcons({ icons });
fileControls.classList.remove('hidden');
rasterizeOptions.classList.remove('hidden');
(processBtn as HTMLButtonElement).disabled = false;
} else {
fileDisplayArea.innerHTML = '';
fileControls.classList.add('hidden');
rasterizeOptions.classList.add('hidden');
(processBtn as HTMLButtonElement).disabled = true;
}
};
const resetState = () => {
state.files = [];
state.pdfDoc = null;
updateUI();
};
const rasterize = async () => {
try {
if (state.files.length === 0) {
showAlert('No Files', 'Please select at least one PDF file.');
return;
}
showLoader('Loading engine...');
await pymupdf.load();
// Get options from UI
const dpi = parseInt((document.getElementById('rasterize-dpi') as HTMLSelectElement).value) || 150;
const format = (document.getElementById('rasterize-format') as HTMLSelectElement).value as 'png' | 'jpeg';
const grayscale = (document.getElementById('rasterize-grayscale') as HTMLInputElement).checked;
const total = state.files.length;
let completed = 0;
let failed = 0;
if (total === 1) {
const file = state.files[0];
showLoader(`Rasterizing ${file.name}...`);
const rasterizedBlob = await (pymupdf as any).rasterizePdf(file, {
dpi,
format,
grayscale,
quality: 95
});
const outName = file.name.replace(/\.pdf$/i, '') + '_rasterized.pdf';
downloadFile(rasterizedBlob, outName);
hideLoader();
showAlert('Rasterization Complete', `Successfully rasterized PDF at ${dpi} DPI.`, 'success', () => resetState());
} else {
// Multiple files - create ZIP
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (const file of state.files) {
try {
showLoader(`Rasterizing ${file.name} (${completed + 1}/${total})...`);
const rasterizedBlob = await (pymupdf as any).rasterizePdf(file, {
dpi,
format,
grayscale,
quality: 95
});
const outName = file.name.replace(/\.pdf$/i, '') + '_rasterized.pdf';
zip.file(outName, rasterizedBlob);
completed++;
} catch (error) {
console.error(`Failed to rasterize ${file.name}:`, error);
failed++;
}
}
showLoader('Creating ZIP archive...');
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'rasterized-pdfs.zip');
hideLoader();
if (failed === 0) {
showAlert('Rasterization Complete', `Successfully rasterized ${completed} PDF(s) at ${dpi} DPI.`, 'success', () => resetState());
} else {
showAlert('Rasterization Partial', `Rasterized ${completed} PDF(s), failed ${failed}.`, 'warning', () => resetState());
}
}
} catch (e: any) {
hideLoader();
showAlert('Error', `An error occurred during rasterization. Error: ${e.message}`);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const pdfFiles = Array.from(files).filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
if (pdfFiles.length > 0) {
state.files = [...state.files, ...pdfFiles];
updateUI();
}
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput.click();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', resetState);
}
if (processBtn) {
processBtn.addEventListener('click', rasterize);
}
});

View File

@@ -1,12 +1,9 @@
import { showAlert } from '../ui.js';
import { downloadFile, formatBytes, initializeQpdf, readFileAsArrayBuffer } from '../utils/helpers.js';
import { icons, createIcons } from 'lucide';
import { RemoveRestrictionsState } from '@/types';
interface PageState {
file: File | null;
}
const pageState: PageState = {
const pageState: RemoveRestrictionsState = {
file: null,
};

View File

@@ -0,0 +1,386 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes, getPDFDocument } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
import { PDFDocument as PDFLibDocument, degrees } from 'pdf-lib';
import { renderPagesProgressively, cleanupLazyRendering } from '../utils/render-utils.js';
import * as pdfjsLib from 'pdfjs-dist';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
interface RotateState {
file: File | null;
pdfDoc: PDFLibDocument | null;
pdfJsDoc: pdfjsLib.PDFDocumentProxy | null;
rotations: number[];
}
const pageState: RotateState = {
file: null,
pdfDoc: null,
pdfJsDoc: null,
rotations: [],
};
function resetState() {
cleanupLazyRendering();
pageState.file = null;
pageState.pdfDoc = null;
pageState.pdfJsDoc = null;
pageState.rotations = [];
const fileDisplayArea = document.getElementById('file-display-area');
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
const toolOptions = document.getElementById('tool-options');
if (toolOptions) toolOptions.classList.add('hidden');
const pageThumbnails = document.getElementById('page-thumbnails');
if (pageThumbnails) pageThumbnails.innerHTML = '';
const fileInput = document.getElementById('file-input') as HTMLInputElement;
if (fileInput) fileInput.value = '';
const batchAngle = document.getElementById('batch-custom-angle') as HTMLInputElement;
if (batchAngle) batchAngle.value = '0';
}
function updateAllRotationDisplays() {
for (let i = 0; i < pageState.rotations.length; i++) {
const input = document.getElementById(`page-angle-${i}`) as HTMLInputElement;
if (input) input.value = pageState.rotations[i].toString();
const container = document.querySelector(`[data-page-index="${i}"]`);
if (container) {
const wrapper = container.querySelector('.thumbnail-wrapper') as HTMLElement;
if (wrapper) wrapper.style.transform = `rotate(${-pageState.rotations[i]}deg)`;
}
}
}
function createPageWrapper(canvas: HTMLCanvasElement, pageNumber: number): HTMLElement {
const pageIndex = pageNumber - 1;
const container = document.createElement('div');
container.className = 'page-thumbnail relative bg-gray-700 rounded-lg overflow-hidden';
container.dataset.pageIndex = pageIndex.toString();
container.dataset.pageNumber = pageNumber.toString();
const canvasWrapper = document.createElement('div');
canvasWrapper.className = 'thumbnail-wrapper flex items-center justify-center p-2 h-36';
canvasWrapper.style.transition = 'transform 0.3s ease';
// Apply initial rotation if it exists (negated for canvas display)
const initialRotation = pageState.rotations[pageIndex] || 0;
canvasWrapper.style.transform = `rotate(${-initialRotation}deg)`;
canvas.className = 'max-w-full max-h-full object-contain';
canvasWrapper.appendChild(canvas);
const pageLabel = document.createElement('div');
pageLabel.className = 'absolute top-1 left-1 bg-black bg-opacity-60 text-white text-xs px-2 py-1 rounded';
pageLabel.textContent = `${pageNumber}`;
container.appendChild(canvasWrapper);
container.appendChild(pageLabel);
// Per-page rotation controls - Custom angle input
const controls = document.createElement('div');
controls.className = 'flex items-center justify-center gap-1 p-2 bg-gray-800';
const decrementBtn = document.createElement('button');
decrementBtn.className = 'w-8 h-8 flex items-center justify-center bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600 text-sm';
decrementBtn.textContent = '-';
decrementBtn.onclick = function (e) {
e.stopPropagation();
const input = document.getElementById(`page-angle-${pageIndex}`) as HTMLInputElement;
const current = parseInt(input.value) || 0;
input.value = (current - 1).toString();
};
const angleInput = document.createElement('input');
angleInput.type = 'number';
angleInput.id = `page-angle-${pageIndex}`;
angleInput.value = pageState.rotations[pageIndex]?.toString() || '0';
angleInput.className = 'w-12 h-8 text-center bg-gray-700 border border-gray-600 text-white rounded text-xs';
const incrementBtn = document.createElement('button');
incrementBtn.className = 'w-8 h-8 flex items-center justify-center bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600 text-sm';
incrementBtn.textContent = '+';
incrementBtn.onclick = function (e) {
e.stopPropagation();
const input = document.getElementById(`page-angle-${pageIndex}`) as HTMLInputElement;
const current = parseInt(input.value) || 0;
input.value = (current + 1).toString();
};
const applyBtn = document.createElement('button');
applyBtn.className = 'w-8 h-8 flex items-center justify-center bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600';
applyBtn.innerHTML = '<i data-lucide="rotate-cw" class="w-4 h-4"></i>';
applyBtn.onclick = function (e) {
e.stopPropagation();
const input = document.getElementById(`page-angle-${pageIndex}`) as HTMLInputElement;
const angle = parseInt(input.value) || 0;
pageState.rotations[pageIndex] = angle;
const wrapper = container.querySelector('.thumbnail-wrapper') as HTMLElement;
if (wrapper) wrapper.style.transform = `rotate(${-angle}deg)`;
};
controls.append(decrementBtn, angleInput, incrementBtn, applyBtn);
container.appendChild(controls);
// Re-create icons for the new element
setTimeout(function () {
createIcons({ icons });
}, 0);
return container;
}
async function renderThumbnails() {
const pageThumbnails = document.getElementById('page-thumbnails');
if (!pageThumbnails || !pageState.pdfJsDoc) return;
pageThumbnails.innerHTML = '';
await renderPagesProgressively(
pageState.pdfJsDoc,
pageThumbnails,
createPageWrapper,
{
batchSize: 8,
useLazyLoading: true,
lazyLoadMargin: '200px',
eagerLoadBatches: 2,
onBatchComplete: function () {
createIcons({ icons });
}
}
);
createIcons({ icons });
}
async function updateUI() {
const fileDisplayArea = document.getElementById('file-display-area');
const toolOptions = document.getElementById('tool-options');
if (!fileDisplayArea) return;
fileDisplayArea.innerHTML = '';
if (pageState.file) {
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = pageState.file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(pageState.file.size)} • Loading...`;
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = function () {
resetState();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
createIcons({ icons });
try {
showLoader('Loading PDF...');
const arrayBuffer = await pageState.file.arrayBuffer();
pageState.pdfDoc = await PDFLibDocument.load(arrayBuffer.slice(0), {
ignoreEncryption: true,
throwOnInvalidObject: false
});
pageState.pdfJsDoc = await getPDFDocument({ data: arrayBuffer.slice(0) }).promise;
const pageCount = pageState.pdfDoc.getPageCount();
pageState.rotations = new Array(pageCount).fill(0);
metaSpan.textContent = `${formatBytes(pageState.file.size)}${pageCount} pages`;
await renderThumbnails();
hideLoader();
if (toolOptions) toolOptions.classList.remove('hidden');
} catch (error) {
console.error('Error loading PDF:', error);
hideLoader();
showAlert('Error', 'Failed to load PDF file.');
resetState();
}
} else {
if (toolOptions) toolOptions.classList.add('hidden');
}
}
async function applyRotations() {
if (!pageState.pdfDoc || !pageState.file) {
showAlert('Error', 'Please upload a PDF first.');
return;
}
showLoader('Applying rotations...');
try {
const pageCount = pageState.pdfDoc.getPageCount();
const newPdfDoc = await PDFLibDocument.create();
for (let i = 0; i < pageCount; i++) {
const rotation = pageState.rotations[i] || 0;
const originalPage = pageState.pdfDoc.getPage(i);
const currentRotation = originalPage.getRotation().angle;
const totalRotation = currentRotation + rotation;
console.log(`Page ${i}: rotation=${rotation}, currentRotation=${currentRotation}, totalRotation=${totalRotation}, applying=${-totalRotation}`);
if (totalRotation % 90 === 0) {
const [copiedPage] = await newPdfDoc.copyPages(pageState.pdfDoc, [i]);
copiedPage.setRotation(degrees(totalRotation));
newPdfDoc.addPage(copiedPage);
} else {
const embeddedPage = await newPdfDoc.embedPage(originalPage);
const { width, height } = embeddedPage.scale(1);
const angleRad = (totalRotation * Math.PI) / 180;
const absCos = Math.abs(Math.cos(angleRad));
const absSin = Math.abs(Math.sin(angleRad));
const newWidth = width * absCos + height * absSin;
const newHeight = width * absSin + height * absCos;
const newPage = newPdfDoc.addPage([newWidth, newHeight]);
const x = newWidth / 2 - (width / 2 * Math.cos(angleRad) - height / 2 * Math.sin(angleRad));
const y = newHeight / 2 - (width / 2 * Math.sin(angleRad) + height / 2 * Math.cos(angleRad));
newPage.drawPage(embeddedPage, {
x,
y,
width,
height,
rotate: degrees(totalRotation),
});
}
}
const rotatedPdfBytes = await newPdfDoc.save();
const originalName = pageState.file.name.replace(/\.pdf$/i, '');
downloadFile(
new Blob([new Uint8Array(rotatedPdfBytes)], { type: 'application/pdf' }),
`${originalName}_rotated.pdf`
);
showAlert('Success', 'Rotations applied successfully!', 'success', function () {
resetState();
});
} catch (e) {
console.error(e);
showAlert('Error', 'Could not apply rotations.');
} finally {
hideLoader();
}
}
function handleFileSelect(files: FileList | null) {
if (files && files.length > 0) {
const file = files[0];
if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
pageState.file = file;
updateUI();
}
}
}
document.addEventListener('DOMContentLoaded', function () {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const backBtn = document.getElementById('back-to-tools');
const batchDecrement = document.getElementById('batch-decrement');
const batchIncrement = document.getElementById('batch-increment');
const batchApply = document.getElementById('batch-apply');
const batchAngleInput = document.getElementById('batch-custom-angle') as HTMLInputElement;
if (backBtn) {
backBtn.addEventListener('click', function () {
window.location.href = import.meta.env.BASE_URL;
});
}
if (batchDecrement && batchAngleInput) {
batchDecrement.addEventListener('click', function () {
const current = parseInt(batchAngleInput.value) || 0;
batchAngleInput.value = (current - 1).toString();
});
}
if (batchIncrement && batchAngleInput) {
batchIncrement.addEventListener('click', function () {
const current = parseInt(batchAngleInput.value) || 0;
batchAngleInput.value = (current + 1).toString();
});
}
if (batchApply && batchAngleInput) {
batchApply.addEventListener('click', function () {
const angle = parseInt(batchAngleInput.value) || 0;
for (let i = 0; i < pageState.rotations.length; i++) {
pageState.rotations[i] = angle;
}
updateAllRotationDisplays();
});
}
if (fileInput && dropZone) {
fileInput.addEventListener('change', function (e) {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', function (e) {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', function (e) {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', function (e) {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const pdfFiles = Array.from(files).filter(function (f) {
return f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf');
});
if (pdfFiles.length > 0) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(pdfFiles[0]);
handleFileSelect(dataTransfer.files);
}
}
});
fileInput.addEventListener('click', function () {
fileInput.value = '';
});
}
if (processBtn) {
processBtn.addEventListener('click', applyRotations);
}
});

View File

@@ -39,19 +39,14 @@ function resetState() {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
if (fileInput) fileInput.value = '';
const batchAngle = document.getElementById('batch-custom-angle') as HTMLInputElement;
if (batchAngle) batchAngle.value = '0';
}
function updateAllRotationDisplays() {
for (let i = 0; i < pageState.rotations.length; i++) {
const input = document.getElementById(`page-angle-${i}`) as HTMLInputElement;
if (input) input.value = pageState.rotations[i].toString();
const container = document.querySelector(`[data-page-index="${i}"]`);
if (container) {
const wrapper = container.querySelector('.thumbnail-wrapper') as HTMLElement;
if (wrapper) wrapper.style.transform = `rotate(${-pageState.rotations[i]}deg)`;
if (wrapper) wrapper.style.transform = `rotate(${pageState.rotations[i]}deg)`;
}
}
}
@@ -67,6 +62,9 @@ function createPageWrapper(canvas: HTMLCanvasElement, pageNumber: number): HTMLE
const canvasWrapper = document.createElement('div');
canvasWrapper.className = 'thumbnail-wrapper flex items-center justify-center p-2 h-36';
canvasWrapper.style.transition = 'transform 0.3s ease';
// Apply initial rotation if it exists
const initialRotation = pageState.rotations[pageIndex] || 0;
canvasWrapper.style.transform = `rotate(${initialRotation}deg)`;
canvas.className = 'max-w-full max-h-full object-contain';
canvasWrapper.appendChild(canvas);
@@ -78,49 +76,31 @@ function createPageWrapper(canvas: HTMLCanvasElement, pageNumber: number): HTMLE
container.appendChild(canvasWrapper);
container.appendChild(pageLabel);
// Per-page rotation controls
// Per-page rotation controls - Left and Right buttons only
const controls = document.createElement('div');
controls.className = 'flex items-center justify-center gap-1 p-2 bg-gray-800';
controls.className = 'flex items-center justify-center gap-2 p-2 bg-gray-800';
const decrementBtn = document.createElement('button');
decrementBtn.className = 'w-8 h-8 flex items-center justify-center bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600 text-sm';
decrementBtn.textContent = '';
decrementBtn.onclick = function (e) {
const rotateLeftBtn = document.createElement('button');
rotateLeftBtn.className = 'flex items-center gap-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600 text-xs';
rotateLeftBtn.innerHTML = '<i data-lucide="rotate-ccw" class="w-3 h-3"></i>';
rotateLeftBtn.onclick = function (e) {
e.stopPropagation();
const input = document.getElementById(`page-angle-${pageIndex}`) as HTMLInputElement;
const current = parseInt(input.value) || 0;
input.value = (current - 1).toString();
};
const angleInput = document.createElement('input');
angleInput.type = 'number';
angleInput.id = `page-angle-${pageIndex}`;
angleInput.value = pageState.rotations[pageIndex]?.toString() || '0';
angleInput.className = 'w-12 h-8 text-center bg-gray-700 border border-gray-600 text-white rounded text-xs';
const incrementBtn = document.createElement('button');
incrementBtn.className = 'w-8 h-8 flex items-center justify-center bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600 text-sm';
incrementBtn.textContent = '+';
incrementBtn.onclick = function (e) {
e.stopPropagation();
const input = document.getElementById(`page-angle-${pageIndex}`) as HTMLInputElement;
const current = parseInt(input.value) || 0;
input.value = (current + 1).toString();
};
const applyBtn = document.createElement('button');
applyBtn.className = 'w-8 h-8 flex items-center justify-center bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600';
applyBtn.innerHTML = '<i data-lucide="rotate-cw" class="w-4 h-4"></i>';
applyBtn.onclick = function (e) {
e.stopPropagation();
const input = document.getElementById(`page-angle-${pageIndex}`) as HTMLInputElement;
const angle = parseInt(input.value) || 0;
pageState.rotations[pageIndex] = angle;
pageState.rotations[pageIndex] = pageState.rotations[pageIndex] - 90;
const wrapper = container.querySelector('.thumbnail-wrapper') as HTMLElement;
if (wrapper) wrapper.style.transform = `rotate(${-angle}deg)`;
if (wrapper) wrapper.style.transform = `rotate(${pageState.rotations[pageIndex]}deg)`;
};
controls.append(decrementBtn, angleInput, incrementBtn, applyBtn);
const rotateRightBtn = document.createElement('button');
rotateRightBtn.className = 'flex items-center gap-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600 text-xs';
rotateRightBtn.innerHTML = '<i data-lucide="rotate-cw" class="w-3 h-3"></i>';
rotateRightBtn.onclick = function (e) {
e.stopPropagation();
pageState.rotations[pageIndex] = pageState.rotations[pageIndex] + 90;
const wrapper = container.querySelector('.thumbnail-wrapper') as HTMLElement;
if (wrapper) wrapper.style.transform = `rotate(${pageState.rotations[pageIndex]}deg)`;
};
controls.append(rotateLeftBtn, rotateRightBtn);
container.appendChild(controls);
// Re-create icons for the new element
@@ -240,6 +220,8 @@ async function applyRotations() {
const currentRotation = originalPage.getRotation().angle;
const totalRotation = currentRotation + rotation;
console.log(`Page ${i}: rotation=${rotation}, currentRotation=${currentRotation}, totalRotation=${totalRotation}, applying=${-totalRotation}`);
if (totalRotation % 90 === 0) {
const [copiedPage] = await newPdfDoc.copyPages(pageState.pdfDoc, [i]);
copiedPage.setRotation(degrees(totalRotation));
@@ -306,10 +288,6 @@ document.addEventListener('DOMContentLoaded', function () {
const backBtn = document.getElementById('back-to-tools');
const rotateAllLeft = document.getElementById('rotate-all-left');
const rotateAllRight = document.getElementById('rotate-all-right');
const batchDecrement = document.getElementById('batch-decrement');
const batchIncrement = document.getElementById('batch-increment');
const batchApply = document.getElementById('batch-apply');
const batchAngleInput = document.getElementById('batch-custom-angle') as HTMLInputElement;
if (backBtn) {
backBtn.addEventListener('click', function () {
@@ -320,7 +298,7 @@ document.addEventListener('DOMContentLoaded', function () {
if (rotateAllLeft) {
rotateAllLeft.addEventListener('click', function () {
for (let i = 0; i < pageState.rotations.length; i++) {
pageState.rotations[i] = pageState.rotations[i] + 90;
pageState.rotations[i] = pageState.rotations[i] - 90;
}
updateAllRotationDisplays();
});
@@ -329,38 +307,12 @@ document.addEventListener('DOMContentLoaded', function () {
if (rotateAllRight) {
rotateAllRight.addEventListener('click', function () {
for (let i = 0; i < pageState.rotations.length; i++) {
pageState.rotations[i] = pageState.rotations[i] - 90;
pageState.rotations[i] = pageState.rotations[i] + 90;
}
updateAllRotationDisplays();
});
}
if (batchDecrement && batchAngleInput) {
batchDecrement.addEventListener('click', function () {
const current = parseInt(batchAngleInput.value) || 0;
batchAngleInput.value = (current - 1).toString();
});
}
if (batchIncrement && batchAngleInput) {
batchIncrement.addEventListener('click', function () {
const current = parseInt(batchAngleInput.value) || 0;
batchAngleInput.value = (current + 1).toString();
});
}
if (batchApply && batchAngleInput) {
batchApply.addEventListener('click', function () {
const angle = parseInt(batchAngleInput.value) || 0;
if (angle !== 0) {
for (let i = 0; i < pageState.rotations.length; i++) {
pageState.rotations[i] = pageState.rotations[i] + angle;
}
updateAllRotationDisplays();
}
});
}
if (fileInput && dropZone) {
fileInput.addEventListener('change', function (e) {
handleFileSelect((e.target as HTMLInputElement).files);

View File

@@ -0,0 +1,215 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import {
downloadFile,
readFileAsArrayBuffer,
formatBytes,
} from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { getLibreOfficeConverter, type LoadProgress } from '../utils/libreoffice-loader.js';
document.addEventListener('DOMContentLoaded', () => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const convertOptions = document.getElementById('convert-options');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const processBtn = document.getElementById('process-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!convertOptions) return;
if (state.files.length > 0) {
if (fileDisplayArea) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
}
if (fileControls) fileControls.classList.remove('hidden');
convertOptions.classList.remove('hidden');
} else {
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
if (fileControls) fileControls.classList.add('hidden');
convertOptions.classList.add('hidden');
}
};
const resetState = () => {
state.files = [];
state.pdfDoc = null;
updateUI();
};
const convertToPdf = async () => {
try {
if (state.files.length === 0) {
showAlert('No Files', 'Please select at least one RTF file.');
hideLoader();
return;
}
const converter = getLibreOfficeConverter();
// Initialize LibreOffice if not already done
await converter.initialize((progress: LoadProgress) => {
showLoader(progress.message, progress.percent);
});
if (state.files.length === 1) {
const originalFile = state.files[0];
showLoader('Processing...');
const pdfBlob = await converter.convertToPdf(originalFile);
const fileName = originalFile.name.replace(/\.rtf$/i, '') + '.pdf';
downloadFile(pdfBlob, fileName);
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${originalFile.name} to PDF.`,
'success',
() => resetState()
);
} else {
showLoader('Converting multiple RTF files to PDF...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.rtf$/i, '');
const pdfBuffer = await pdfBlob.arrayBuffer();
zip.file(`${baseName}.pdf`, pdfBuffer);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'rtf-converted.zip');
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} RTF file(s) to PDF.`,
'success',
() => resetState()
);
}
} catch (e: any) {
hideLoader();
showAlert(
'Error',
`An error occurred during conversion. Error: ${e.message}`
);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
state.files = [...state.files, ...Array.from(files)];
updateUI();
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const rtfFiles = Array.from(files).filter(f => f.name.toLowerCase().endsWith('.rtf') || f.type === 'text/rtf' || f.type === 'application/rtf');
if (rtfFiles.length > 0) {
const dataTransfer = new DataTransfer();
rtfFiles.forEach(f => dataTransfer.items.add(f));
handleFileSelect(dataTransfer.files);
}
}
});
// Clear value on click to allow re-selecting the same file
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput.click();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', () => {
resetState();
});
}
if (processBtn) {
processBtn.addEventListener('click', convertToPdf);
}
updateUI();
});

View File

@@ -2,13 +2,9 @@ import { showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { PDFDocument, PDFName } from 'pdf-lib';
import { icons, createIcons } from 'lucide';
import { SanitizePdfState } from '@/types';
interface PageState {
file: File | null;
pdfDoc: PDFDocument | null;
}
const pageState: PageState = {
const pageState: SanitizePdfState = {
file: null,
pdfDoc: null,
};

View File

@@ -253,8 +253,13 @@ document.addEventListener('DOMContentLoaded', () => {
const pageRangeInput = (document.getElementById('page-range') as HTMLInputElement).value;
if (!pageRangeInput) throw new Error('Choose a valid page range.');
const ranges = pageRangeInput.split(',');
const rangeGroups: number[][] = [];
for (const range of ranges) {
const trimmedRange = range.trim();
if (!trimmedRange) continue;
const groupIndices: number[] = [];
if (trimmedRange.includes('-')) {
const [start, end] = trimmedRange.split('-').map(Number);
if (
@@ -265,12 +270,45 @@ document.addEventListener('DOMContentLoaded', () => {
start > end
)
continue;
for (let i = start; i <= end; i++) indicesToExtract.push(i - 1);
for (let i = start; i <= end; i++) groupIndices.push(i - 1);
} else {
const pageNum = Number(trimmedRange);
if (isNaN(pageNum) || pageNum < 1 || pageNum > totalPages) continue;
indicesToExtract.push(pageNum - 1);
groupIndices.push(pageNum - 1);
}
if (groupIndices.length > 0) {
rangeGroups.push(groupIndices);
indicesToExtract.push(...groupIndices);
}
}
if (rangeGroups.length > 1) {
showLoader('Creating separate PDFs for each range...');
const zip = new JSZip();
for (let i = 0; i < rangeGroups.length; i++) {
const group = rangeGroups[i];
const newPdf = await PDFLibDocument.create();
const copiedPages = await newPdf.copyPages(state.pdfDoc, group);
copiedPages.forEach((page: any) => newPdf.addPage(page));
const pdfBytes = await newPdf.save();
const minPage = Math.min(...group) + 1;
const maxPage = Math.max(...group) + 1;
const filename = minPage === maxPage
? `page-${minPage}.pdf`
: `pages-${minPage}-${maxPage}.pdf`;
zip.file(filename, pdfBytes);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'split-pages.zip');
hideLoader();
showAlert('Success', `PDF split into ${rangeGroups.length} files successfully!`, 'success', () => {
resetState();
});
return;
}
break;

View File

@@ -3,11 +3,11 @@ import { showAlert, showLoader, hideLoader } from '../ui.js';
import { downloadFile, hexToRgb, formatBytes, getPDFDocument, readFileAsArrayBuffer } from '../utils/helpers.js';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
import { TextColorState } from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
interface PageState { file: File | null; pdfDoc: PDFLibDocument | null; }
const pageState: PageState = { file: null, pdfDoc: null };
const pageState: TextColorState = { file: null, pdfDoc: null };
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializePage); }
else { initializePage(); }

View File

@@ -1,25 +1,18 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes, hexToRgb } from '../utils/helpers.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
import { PDFDocument, rgb, StandardFonts, PageSizes } from 'pdf-lib';
import { getFontForLanguage, getLanguageForChar } from '../utils/font-loader.js';
import { languageToFontFamily } from '../config/font-mappings.js';
import fontkit from '@pdf-lib/fontkit';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
let files: File[] = [];
let currentMode: 'upload' | 'text' = 'upload';
let selectedLanguages: string[] = ['eng'];
const allLanguages = Object.keys(languageToFontFamily).sort().map(code => {
let name = code;
try {
const displayNames = new Intl.DisplayNames(['en'], { type: 'language' });
name = displayNames.of(code) || code;
} catch (e) {
console.warn(`Failed to get language name for ${code}`, e);
}
return { code, name: `${name} (${code})` };
});
// RTL character detection pattern (Arabic, Hebrew, Persian, etc.)
const RTL_PATTERN = /[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u0780-\u07BF\u07C0-\u07FF\u08A0-\u08FF\uFB1D-\uFB4F\uFB50-\uFDFF\uFE70-\uFEFF]/;
function hasRtlCharacters(text: string): boolean {
return RTL_PATTERN.test(text);
}
const updateUI = () => {
const fileDisplayArea = document.getElementById('file-display-area');
@@ -73,140 +66,11 @@ const resetState = () => {
updateUI();
};
async function createPdfFromText(
text: string,
fontSize: number,
pageSizeKey: string,
colorHex: string,
orientation: string,
customWidth?: number,
customHeight?: number
): Promise<Uint8Array> {
const pdfDoc = await PDFDocument.create();
pdfDoc.registerFontkit(fontkit);
const fontMap = new Map<string, any>();
const fallbackFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
if (!selectedLanguages.includes('eng')) {
selectedLanguages.push('eng');
}
for (const lang of selectedLanguages) {
try {
const fontBytes = await getFontForLanguage(lang);
const font = await pdfDoc.embedFont(fontBytes, { subset: false });
fontMap.set(lang, font);
} catch (e) {
console.warn(`Failed to load font for ${lang}, using fallback`, e);
fontMap.set(lang, fallbackFont);
}
}
let pageSize = pageSizeKey === 'Custom'
? [customWidth || 595, customHeight || 842] as [number, number]
: (PageSizes as any)[pageSizeKey];
if (orientation === 'landscape') {
pageSize = [pageSize[1], pageSize[0]] as [number, number];
}
const margin = 72;
const textColor = hexToRgb(colorHex);
let page = pdfDoc.addPage(pageSize);
let { width, height } = page.getSize();
const textWidth = width - margin * 2;
const lineHeight = fontSize * 1.3;
let y = height - margin;
const paragraphs = text.split('\n');
for (const paragraph of paragraphs) {
if (paragraph.trim() === '') {
y -= lineHeight;
if (y < margin) {
page = pdfDoc.addPage(pageSize);
y = page.getHeight() - margin;
}
continue;
}
const words = paragraph.split(' ');
let currentLineWords: { text: string; font: any }[] = [];
let currentLineWidth = 0;
for (const word of words) {
let wordLang = 'eng';
for (const char of word) {
const charLang = getLanguageForChar(char);
if (selectedLanguages.includes(charLang)) {
wordLang = charLang;
break;
}
}
const font = fontMap.get(wordLang) || fontMap.get('eng') || fallbackFont;
const wordWidth = font.widthOfTextAtSize(word + ' ', fontSize);
if (currentLineWidth + wordWidth > textWidth && currentLineWords.length > 0) {
currentLineWords.forEach((item, idx) => {
const x = margin + (currentLineWidth * idx / currentLineWords.length);
page.drawText(item.text, {
x: margin + (currentLineWidth - textWidth) / 2,
y: y,
size: fontSize,
font: item.font,
color: rgb(textColor.r / 255, textColor.g / 255, textColor.b / 255),
});
});
currentLineWords = [];
currentLineWidth = 0;
y -= lineHeight;
if (y < margin) {
page = pdfDoc.addPage(pageSize);
y = page.getHeight() - margin;
}
}
currentLineWords.push({ text: word + ' ', font });
currentLineWidth += wordWidth;
}
if (currentLineWords.length > 0) {
let x = margin;
currentLineWords.forEach((item) => {
page.drawText(item.text, {
x: x,
y: y,
size: fontSize,
font: item.font,
color: rgb(textColor.r / 255, textColor.g / 255, textColor.b / 255),
});
x += item.font.widthOfTextAtSize(item.text, fontSize);
});
y -= lineHeight;
if (y < margin) {
page = pdfDoc.addPage(pageSize);
y = page.getHeight() - margin;
}
}
}
return await pdfDoc.save();
}
async function convert() {
const fontSize = parseInt((document.getElementById('font-size') as HTMLInputElement).value) || 12;
const pageSizeKey = (document.getElementById('page-size') as HTMLSelectElement).value;
const colorHex = (document.getElementById('text-color') as HTMLInputElement).value;
const orientation = (document.getElementById('page-orientation') as HTMLSelectElement).value;
const customWidth = parseInt((document.getElementById('custom-width') as HTMLInputElement)?.value);
const customHeight = parseInt((document.getElementById('custom-height') as HTMLInputElement)?.value);
const fontName = (document.getElementById('font-family') as HTMLSelectElement)?.value || 'helv';
const textColor = (document.getElementById('text-color') as HTMLInputElement)?.value || '#000000';
if (currentMode === 'upload' && files.length === 0) {
showAlert('No Files', 'Please select at least one text file.');
@@ -221,58 +85,59 @@ async function convert() {
}
}
showLoader('Creating PDF...');
showLoader('Loading engine...');
try {
const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
await pymupdf.load();
let textContent = '';
if (currentMode === 'upload') {
let combinedText = '';
for (const file of files) {
const text = await file.text();
combinedText += text + '\n\n';
textContent += text + '\n\n';
}
const pdfBytes = await createPdfFromText(
combinedText,
fontSize,
pageSizeKey,
colorHex,
orientation,
customWidth,
customHeight
);
downloadFile(
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
'from_text.pdf'
);
} else {
const textInput = document.getElementById('text-input') as HTMLTextAreaElement;
const pdfBytes = await createPdfFromText(
textInput.value,
fontSize,
pageSizeKey,
colorHex,
orientation,
customWidth,
customHeight
);
downloadFile(
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
'from_text.pdf'
);
textContent = textInput.value;
}
showLoader('Creating PDF...');
const pdfBlob = await pymupdf.textToPdf(textContent, {
fontSize,
pageSize: pageSizeKey as 'a4' | 'letter' | 'legal' | 'a3' | 'a5',
fontName: fontName as 'helv' | 'tiro' | 'cour' | 'times',
textColor,
margins: 72
});
downloadFile(pdfBlob, 'text_to_pdf.pdf');
showAlert('Success', 'Text converted to PDF successfully!', 'success', () => {
resetState();
});
} catch (e) {
console.error(e);
showAlert('Error', 'Failed to convert text to PDF.');
} catch (e: any) {
console.error('[TxtToPDF] Error:', e);
showAlert('Error', `Failed to convert text to PDF. ${e.message || ''}`);
} finally {
hideLoader();
}
}
// Update textarea direction based on RTL detection
function updateTextareaDirection(textarea: HTMLTextAreaElement) {
const text = textarea.value;
if (hasRtlCharacters(text)) {
textarea.style.direction = 'rtl';
textarea.style.textAlign = 'right';
} else {
textarea.style.direction = 'ltr';
textarea.style.textAlign = 'left';
}
}
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
@@ -284,12 +149,7 @@ document.addEventListener('DOMContentLoaded', () => {
const textModeBtn = document.getElementById('txt-mode-text-btn');
const uploadPanel = document.getElementById('txt-upload-panel');
const textPanel = document.getElementById('txt-text-panel');
const pageSizeSelect = document.getElementById('page-size') as HTMLSelectElement;
const customSizeContainer = document.getElementById('custom-size-container');
const langDropdownBtn = document.getElementById('lang-dropdown-btn');
const langDropdownContent = document.getElementById('lang-dropdown-content');
const langSearch = document.getElementById('lang-search') as HTMLInputElement;
const langContainer = document.getElementById('language-list-container');
const textInput = document.getElementById('text-input') as HTMLTextAreaElement;
// Back to Tools
if (backBtn) {
@@ -321,86 +181,13 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// Custom page size toggle
if (pageSizeSelect && customSizeContainer) {
pageSizeSelect.addEventListener('change', () => {
if (pageSizeSelect.value === 'Custom') {
customSizeContainer.classList.remove('hidden');
} else {
customSizeContainer.classList.add('hidden');
}
// RTL auto-detection for textarea
if (textInput) {
textInput.addEventListener('input', () => {
updateTextareaDirection(textInput);
});
}
// Language dropdown
if (langDropdownBtn && langDropdownContent && langContainer) {
// Populate language list
allLanguages.forEach(lang => {
const label = document.createElement('label');
label.className = 'flex items-center gap-2 p-2 hover:bg-gray-700 rounded cursor-pointer';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = lang.code;
checkbox.className = 'w-4 h-4';
checkbox.checked = lang.code === 'eng';
checkbox.addEventListener('change', () => {
if (checkbox.checked) {
if (!selectedLanguages.includes(lang.code)) {
selectedLanguages.push(lang.code);
}
} else {
selectedLanguages = selectedLanguages.filter(l => l !== lang.code);
}
updateLanguageDisplay();
});
const span = document.createElement('span');
span.textContent = lang.name;
span.className = 'text-sm text-gray-300';
label.append(checkbox, span);
langContainer.appendChild(label);
});
langDropdownBtn.addEventListener('click', () => {
langDropdownContent.classList.toggle('hidden');
});
document.addEventListener('click', (e) => {
if (!langDropdownBtn.contains(e.target as Node) && !langDropdownContent.contains(e.target as Node)) {
langDropdownContent.classList.add('hidden');
}
});
if (langSearch) {
langSearch.addEventListener('input', () => {
const searchTerm = langSearch.value.toLowerCase();
const labels = langContainer.querySelectorAll('label');
labels.forEach(label => {
const text = label.textContent?.toLowerCase() || '';
if (text.includes(searchTerm)) {
(label as HTMLElement).style.display = 'flex';
} else {
(label as HTMLElement).style.display = 'none';
}
});
});
}
}
function updateLanguageDisplay() {
const langDropdownText = document.getElementById('lang-dropdown-text');
if (langDropdownText) {
const selectedNames = selectedLanguages.map(code => {
const lang = allLanguages.find(l => l.code === code);
return lang?.name || code;
});
langDropdownText.textContent = selectedNames.length > 0 ? selectedNames.join(', ') : 'Select Languages';
}
}
// File handling
const handleFileSelect = (newFiles: FileList | null) => {
if (!newFiles || newFiles.length === 0) return;

View File

@@ -0,0 +1,469 @@
import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { readFileAsArrayBuffer, formatBytes } from '../utils/helpers.js';
import { validatePdfSignatures } from './validate-signature-pdf.js';
import forge from 'node-forge';
import { SignatureValidationResult, ValidateSignatureState } from '@/types';
const state: ValidateSignatureState = {
pdfFile: null,
pdfBytes: null,
results: [],
trustedCertFile: null,
trustedCert: null,
};
function getElement<T extends HTMLElement>(id: string): T | null {
return document.getElementById(id) as T | null;
}
function resetState(): void {
state.pdfFile = null;
state.pdfBytes = null;
state.results = [];
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
const resultsSection = getElement<HTMLDivElement>('results-section');
if (resultsSection) resultsSection.classList.add('hidden');
const resultsContainer = getElement<HTMLDivElement>('results-container');
if (resultsContainer) resultsContainer.innerHTML = '';
const fileInput = getElement<HTMLInputElement>('file-input');
if (fileInput) fileInput.value = '';
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
if (customCertSection) customCertSection.classList.add('hidden');
}
function resetCertState(): void {
state.trustedCertFile = null;
state.trustedCert = null;
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
if (certDisplayArea) certDisplayArea.innerHTML = '';
const certInput = getElement<HTMLInputElement>('cert-input');
if (certInput) certInput.value = '';
}
function initializePage(): void {
createIcons({ icons });
const fileInput = getElement<HTMLInputElement>('file-input');
const dropZone = getElement<HTMLDivElement>('drop-zone');
const backBtn = getElement<HTMLButtonElement>('back-to-tools');
const certInput = getElement<HTMLInputElement>('cert-input');
const certDropZone = getElement<HTMLDivElement>('cert-drop-zone');
if (fileInput) {
fileInput.addEventListener('change', handlePdfUpload);
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (dropZone) {
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
handlePdfFile(droppedFiles[0]);
}
});
}
if (certInput) {
certInput.addEventListener('change', handleCertUpload);
certInput.addEventListener('click', () => {
certInput.value = '';
});
}
if (certDropZone) {
certDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
certDropZone.classList.add('bg-gray-700');
});
certDropZone.addEventListener('dragleave', () => {
certDropZone.classList.remove('bg-gray-700');
});
certDropZone.addEventListener('drop', (e) => {
e.preventDefault();
certDropZone.classList.remove('bg-gray-700');
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
handleCertFile(droppedFiles[0]);
}
});
}
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
}
function handlePdfUpload(e: Event): void {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handlePdfFile(input.files[0]);
}
}
async function handlePdfFile(file: File): Promise<void> {
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
showAlert('Invalid File', 'Please select a PDF file.');
return;
}
resetState();
state.pdfFile = file;
state.pdfBytes = new Uint8Array(await readFileAsArrayBuffer(file) as ArrayBuffer);
updatePdfDisplay();
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
if (customCertSection) customCertSection.classList.remove('hidden');
createIcons({ icons });
await validateSignatures();
}
function updatePdfDisplay(): void {
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
if (!fileDisplayArea || !state.pdfFile) return;
fileDisplayArea.innerHTML = '';
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = state.pdfFile.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(state.pdfFile.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => resetState();
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
createIcons({ icons });
}
function handleCertUpload(e: Event): void {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handleCertFile(input.files[0]);
}
}
async function handleCertFile(file: File): Promise<void> {
const validExtensions = ['.pem', '.crt', '.cer', '.der'];
const hasValidExtension = validExtensions.some(ext => file.name.toLowerCase().endsWith(ext));
if (!hasValidExtension) {
showAlert('Invalid Certificate', 'Please select a .pem, .crt, .cer, or .der certificate file.');
return;
}
resetCertState();
state.trustedCertFile = file;
try {
const content = await file.text();
if (content.includes('-----BEGIN CERTIFICATE-----')) {
state.trustedCert = forge.pki.certificateFromPem(content);
} else {
const bytes = new Uint8Array(await readFileAsArrayBuffer(file) as ArrayBuffer);
const derString = String.fromCharCode.apply(null, Array.from(bytes));
const asn1 = forge.asn1.fromDer(derString);
state.trustedCert = forge.pki.certificateFromAsn1(asn1);
}
updateCertDisplay();
if (state.pdfBytes) {
await validateSignatures();
}
} catch (error) {
console.error('Error parsing certificate:', error);
showAlert('Invalid Certificate', 'Failed to parse the certificate file.');
resetCertState();
}
}
function updateCertDisplay(): void {
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
if (!certDisplayArea || !state.trustedCertFile || !state.trustedCert) return;
certDisplayArea.innerHTML = '';
const certDiv = document.createElement('div');
certDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
const cn = state.trustedCert.subject.getField('CN');
nameSpan.textContent = cn?.value as string || state.trustedCertFile.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-green-400';
metaSpan.innerHTML = '<i data-lucide="check-circle" class="inline w-3 h-3 mr-1"></i>Trusted certificate loaded';
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = async () => {
resetCertState();
if (state.pdfBytes) {
await validateSignatures();
}
};
certDiv.append(infoContainer, removeBtn);
certDisplayArea.appendChild(certDiv);
createIcons({ icons });
}
async function validateSignatures(): Promise<void> {
if (!state.pdfBytes) return;
showLoader('Analyzing signatures...');
try {
state.results = await validatePdfSignatures(state.pdfBytes, state.trustedCert ?? undefined);
displayResults();
} catch (error) {
console.error('Validation error:', error);
showAlert('Error', 'Failed to validate signatures. The file may be corrupted.');
} finally {
hideLoader();
}
}
function displayResults(): void {
const resultsSection = getElement<HTMLDivElement>('results-section');
const resultsContainer = getElement<HTMLDivElement>('results-container');
if (!resultsSection || !resultsContainer) return;
resultsContainer.innerHTML = '';
resultsSection.classList.remove('hidden');
if (state.results.length === 0) {
resultsContainer.innerHTML = `
<div class="bg-gray-700 rounded-lg p-6 text-center border border-gray-600">
<i data-lucide="file-x" class="w-12 h-12 mx-auto mb-4 text-gray-400"></i>
<h3 class="text-lg font-semibold text-white mb-2">No Signatures Found</h3>
<p class="text-gray-400">This PDF does not contain any digital signatures.</p>
</div>
`;
createIcons({ icons });
return;
}
const summaryDiv = document.createElement('div');
summaryDiv.className = 'mb-4 p-3 bg-gray-700 rounded-lg border border-gray-600';
const validCount = state.results.filter(r => r.isValid && !r.isExpired).length;
const trustVerified = state.trustedCert ? state.results.filter(r => r.isTrusted).length : 0;
let summaryHtml = `
<p class="text-gray-300">
<span class="font-semibold text-white">${state.results.length}</span>
signature${state.results.length > 1 ? 's' : ''} found
<span class="text-gray-500">•</span>
<span class="${validCount === state.results.length ? 'text-green-400' : 'text-yellow-400'}">${validCount} valid</span>
</p>
`;
if (state.trustedCert) {
summaryHtml += `
<p class="text-xs text-gray-400 mt-1">
<i data-lucide="shield-check" class="inline w-3 h-3 mr-1"></i>
Trust verification: ${trustVerified}/${state.results.length} signatures verified against custom certificate
</p>
`;
}
summaryDiv.innerHTML = summaryHtml;
resultsContainer.appendChild(summaryDiv);
state.results.forEach((result, index) => {
const card = createSignatureCard(result, index);
resultsContainer.appendChild(card);
});
createIcons({ icons });
}
function createSignatureCard(result: SignatureValidationResult, index: number): HTMLElement {
const card = document.createElement('div');
card.className = 'bg-gray-700 rounded-lg p-4 border border-gray-600 mb-4';
let statusColor = 'text-green-400';
let statusIcon = 'check-circle';
let statusText = 'Valid Signature';
if (!result.isValid) {
statusColor = 'text-red-400';
statusIcon = 'x-circle';
statusText = 'Invalid Signature';
} else if (result.isExpired) {
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = 'Certificate Expired';
} else if (result.isSelfSigned) {
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = 'Self-Signed Certificate';
}
const formatDate = (date: Date) => {
if (!date || date.getTime() === 0) return 'Unknown';
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
let trustBadge = '';
if (state.trustedCert) {
if (result.isTrusted) {
trustBadge = '<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded ml-2"><i data-lucide="shield-check" class="inline w-3 h-3 mr-1"></i>Trusted</span>';
} else {
trustBadge = '<span class="text-xs bg-gray-600 text-gray-300 px-2 py-1 rounded ml-2"><i data-lucide="shield-x" class="inline w-3 h-3 mr-1"></i>Not in trust chain</span>';
}
}
card.innerHTML = `
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3">
<i data-lucide="${statusIcon}" class="w-6 h-6 ${statusColor}"></i>
<div>
<h3 class="font-semibold text-white">Signature ${index + 1}</h3>
<p class="text-sm ${statusColor}">${statusText}</p>
</div>
</div>
<div class="flex items-center">
${result.coverageStatus === 'full'
? '<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded">Full Coverage</span>'
: result.coverageStatus === 'partial'
? '<span class="text-xs bg-yellow-900 text-yellow-300 px-2 py-1 rounded">Partial Coverage</span>'
: ''
}${trustBadge}
</div>
</div>
<div class="space-y-3 text-sm">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-gray-400">Signed By</p>
<p class="text-white font-medium">${escapeHtml(result.signerName)}</p>
${result.signerOrg ? `<p class="text-gray-400 text-xs">${escapeHtml(result.signerOrg)}</p>` : ''}
${result.signerEmail ? `<p class="text-gray-400 text-xs">${escapeHtml(result.signerEmail)}</p>` : ''}
</div>
<div>
<p class="text-gray-400">Issuer</p>
<p class="text-white font-medium">${escapeHtml(result.issuer)}</p>
${result.issuerOrg ? `<p class="text-gray-400 text-xs">${escapeHtml(result.issuerOrg)}</p>` : ''}
</div>
</div>
${result.signatureDate ? `
<div>
<p class="text-gray-400">Signed On</p>
<p class="text-white">${formatDate(result.signatureDate)}</p>
</div>
` : ''}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-gray-400">Valid From</p>
<p class="text-white">${formatDate(result.validFrom)}</p>
</div>
<div>
<p class="text-gray-400">Valid Until</p>
<p class="${result.isExpired ? 'text-red-400' : 'text-white'}">${formatDate(result.validTo)}</p>
</div>
</div>
${result.reason ? `
<div>
<p class="text-gray-400">Reason</p>
<p class="text-white">${escapeHtml(result.reason)}</p>
</div>
` : ''}
${result.location ? `
<div>
<p class="text-gray-400">Location</p>
<p class="text-white">${escapeHtml(result.location)}</p>
</div>
` : ''}
<details class="mt-2">
<summary class="cursor-pointer text-indigo-400 hover:text-indigo-300 text-sm">
Technical Details
</summary>
<div class="mt-2 p-3 bg-gray-800 rounded text-xs space-y-1">
<p><span class="text-gray-400">Serial Number:</span> <span class="text-gray-300 font-mono">${escapeHtml(result.serialNumber)}</span></p>
<p><span class="text-gray-400">Digest Algorithm:</span> <span class="text-gray-300">${escapeHtml(result.algorithms.digest)}</span></p>
<p><span class="text-gray-400">Signature Algorithm:</span> <span class="text-gray-300">${escapeHtml(result.algorithms.signature)}</span></p>
${result.errorMessage ? `<p class="text-red-400">Error: ${escapeHtml(result.errorMessage)}</p>` : ''}
</div>
</details>
</div>
`;
return card;
}
function escapeHtml(str: string): string {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}

View File

@@ -0,0 +1,238 @@
import forge from 'node-forge';
import { ExtractedSignature, SignatureValidationResult } from '@/types';
export function extractSignatures(pdfBytes: Uint8Array): ExtractedSignature[] {
const signatures: ExtractedSignature[] = [];
const pdfString = new TextDecoder('latin1').decode(pdfBytes);
// Find all signature objects for /Type /Sig
const sigRegex = /\/Type\s*\/Sig\b/g;
let sigMatch;
let sigIndex = 0;
while ((sigMatch = sigRegex.exec(pdfString)) !== null) {
try {
const searchStart = Math.max(0, sigMatch.index - 5000);
const searchEnd = Math.min(pdfString.length, sigMatch.index + 10000);
const context = pdfString.substring(searchStart, searchEnd);
const byteRangeMatch = context.match(/\/ByteRange\s*\[\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*\]/);
if (!byteRangeMatch) continue;
const byteRange = [
parseInt(byteRangeMatch[1], 10),
parseInt(byteRangeMatch[2], 10),
parseInt(byteRangeMatch[3], 10),
parseInt(byteRangeMatch[4], 10),
];
const contentsMatch = context.match(/\/Contents\s*<([0-9A-Fa-f]+)>/);
if (!contentsMatch) continue;
const hexContents = contentsMatch[1];
const contentsBytes = hexToBytes(hexContents);
const reasonMatch = context.match(/\/Reason\s*\(([^)]*)\)/);
const locationMatch = context.match(/\/Location\s*\(([^)]*)\)/);
const contactMatch = context.match(/\/ContactInfo\s*\(([^)]*)\)/);
const nameMatch = context.match(/\/Name\s*\(([^)]*)\)/);
const timeMatch = context.match(/\/M\s*\(D:(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/);
let signingTime: string | undefined;
if (timeMatch) {
signingTime = `${timeMatch[1]}-${timeMatch[2]}-${timeMatch[3]}T${timeMatch[4]}:${timeMatch[5]}:${timeMatch[6]}`;
}
signatures.push({
index: sigIndex++,
contents: contentsBytes,
byteRange,
reason: reasonMatch ? decodeURIComponent(escape(reasonMatch[1])) : undefined,
location: locationMatch ? decodeURIComponent(escape(locationMatch[1])) : undefined,
contactInfo: contactMatch ? decodeURIComponent(escape(contactMatch[1])) : undefined,
name: nameMatch ? decodeURIComponent(escape(nameMatch[1])) : undefined,
signingTime,
});
} catch (e) {
console.warn('Error extracting signature at index', sigIndex, e);
}
}
return signatures;
}
export function validateSignature(
signature: ExtractedSignature,
pdfBytes: Uint8Array,
trustedCert?: forge.pki.Certificate
): SignatureValidationResult {
const result: SignatureValidationResult = {
signatureIndex: signature.index,
isValid: false,
signerName: 'Unknown',
issuer: 'Unknown',
validFrom: new Date(0),
validTo: new Date(0),
isExpired: false,
isSelfSigned: false,
isTrusted: false,
algorithms: { digest: 'Unknown', signature: 'Unknown' },
serialNumber: '',
byteRange: signature.byteRange,
coverageStatus: 'unknown',
reason: signature.reason,
location: signature.location,
contactInfo: signature.contactInfo,
};
try {
const binaryString = String.fromCharCode.apply(null, Array.from(signature.contents));
const asn1 = forge.asn1.fromDer(binaryString);
const p7 = forge.pkcs7.messageFromAsn1(asn1) as any;
if (!p7.certificates || p7.certificates.length === 0) {
result.errorMessage = 'No certificates found in signature';
return result;
}
const signerCert = p7.certificates[0] as forge.pki.Certificate;
const subjectCN = signerCert.subject.getField('CN');
const subjectO = signerCert.subject.getField('O');
const subjectE = signerCert.subject.getField('E') || signerCert.subject.getField('emailAddress');
const issuerCN = signerCert.issuer.getField('CN');
const issuerO = signerCert.issuer.getField('O');
result.signerName = (subjectCN?.value as string) ?? 'Unknown';
result.signerOrg = subjectO?.value as string | undefined;
result.signerEmail = subjectE?.value as string | undefined;
result.issuer = (issuerCN?.value as string) ?? 'Unknown';
result.issuerOrg = issuerO?.value as string | undefined;
result.validFrom = signerCert.validity.notBefore;
result.validTo = signerCert.validity.notAfter;
result.serialNumber = signerCert.serialNumber;
const now = new Date();
result.isExpired = now > result.validTo || now < result.validFrom;
result.isSelfSigned = signerCert.isIssuer(signerCert);
// Check trust against provided certificate
if (trustedCert) {
try {
const isTrustedIssuer = trustedCert.isIssuer(signerCert);
const isSameCert = signerCert.serialNumber === trustedCert.serialNumber;
let chainTrusted = false;
for (const cert of p7.certificates) {
if (trustedCert.isIssuer(cert) ||
(cert as forge.pki.Certificate).serialNumber === trustedCert.serialNumber) {
chainTrusted = true;
break;
}
}
result.isTrusted = isTrustedIssuer || isSameCert || chainTrusted;
} catch {
result.isTrusted = false;
}
}
result.algorithms = {
digest: getDigestAlgorithmName(signerCert.siginfo?.algorithmOid || ''),
signature: getSignatureAlgorithmName(signerCert.signatureOid || ''),
};
// Parse signing time if available in signature
if (signature.signingTime) {
result.signatureDate = new Date(signature.signingTime);
} else {
// Try to extract from authenticated attributes
try {
const signedData = p7 as any;
if (signedData.rawCapture?.authenticatedAttributes) {
// Look for signing time attribute
for (const attr of signedData.rawCapture.authenticatedAttributes) {
if (attr.type === forge.pki.oids.signingTime) {
result.signatureDate = attr.value;
break;
}
}
}
} catch { /* ignore */ }
}
if (signature.byteRange && signature.byteRange.length === 4) {
const [start1, len1, start2, len2] = signature.byteRange;
const totalCovered = len1 + len2;
const expectedEnd = start2 + len2;
if (expectedEnd === pdfBytes.length) {
result.coverageStatus = 'full';
} else if (expectedEnd < pdfBytes.length) {
result.coverageStatus = 'partial';
}
}
result.isValid = true;
} catch (e) {
result.errorMessage = e instanceof Error ? e.message : 'Failed to parse signature';
}
return result;
}
export async function validatePdfSignatures(
pdfBytes: Uint8Array,
trustedCert?: forge.pki.Certificate
): Promise<SignatureValidationResult[]> {
const signatures = extractSignatures(pdfBytes);
return signatures.map(sig => validateSignature(sig, pdfBytes, trustedCert));
}
export function countSignatures(pdfBytes: Uint8Array): number {
return extractSignatures(pdfBytes).length;
}
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
let actualLength = bytes.length;
while (actualLength > 0 && bytes[actualLength - 1] === 0) {
actualLength--;
}
return bytes.slice(0, actualLength);
}
function getDigestAlgorithmName(oid: string): string {
const digestAlgorithms: Record<string, string> = {
'1.2.840.113549.2.5': 'MD5',
'1.3.14.3.2.26': 'SHA-1',
'2.16.840.1.101.3.4.2.1': 'SHA-256',
'2.16.840.1.101.3.4.2.2': 'SHA-384',
'2.16.840.1.101.3.4.2.3': 'SHA-512',
'2.16.840.1.101.3.4.2.4': 'SHA-224',
};
return digestAlgorithms[oid] || oid || 'Unknown';
}
function getSignatureAlgorithmName(oid: string): string {
const signatureAlgorithms: Record<string, string> = {
'1.2.840.113549.1.1.1': 'RSA',
'1.2.840.113549.1.1.5': 'RSA with SHA-1',
'1.2.840.113549.1.1.11': 'RSA with SHA-256',
'1.2.840.113549.1.1.12': 'RSA with SHA-384',
'1.2.840.113549.1.1.13': 'RSA with SHA-512',
'1.2.840.10045.2.1': 'ECDSA',
'1.2.840.10045.4.1': 'ECDSA with SHA-1',
'1.2.840.10045.4.3.2': 'ECDSA with SHA-256',
'1.2.840.10045.4.3.3': 'ECDSA with SHA-384',
'1.2.840.10045.4.3.4': 'ECDSA with SHA-512',
};
return signatureAlgorithms[oid] || oid || 'Unknown';
}

View File

@@ -1,11 +1,7 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { formatBytes, formatIsoDate, getPDFDocument } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
interface ViewMetadataState {
file: File | null;
metadata: Record<string, unknown>;
}
import { ViewMetadataState } from '@/types';
const pageState: ViewMetadataState = {
file: null,

View File

@@ -0,0 +1,142 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { getLibreOfficeConverter, type LoadProgress } from '../utils/libreoffice-loader.js';
const ACCEPTED_EXTENSIONS = ['.vsd', '.vsdx'];
const FILETYPE_NAME = 'VSD';
document.addEventListener('DOMContentLoaded', () => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const convertOptions = document.getElementById('convert-options');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const processBtn = document.getElementById('process-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!convertOptions) return;
if (state.files.length > 0) {
if (fileDisplayArea) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
}
if (fileControls) fileControls.classList.remove('hidden');
convertOptions.classList.remove('hidden');
} else {
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
if (fileControls) fileControls.classList.add('hidden');
convertOptions.classList.add('hidden');
}
};
const resetState = () => {
state.files = [];
updateUI();
};
const convert = async () => {
if (state.files.length === 0) {
showAlert('No Files', `Please select at least one ${FILETYPE_NAME} file.`);
return;
}
try {
const converter = getLibreOfficeConverter();
showLoader('Loading engine...');
await converter.initialize((progress: LoadProgress) => {
showLoader(progress.message, progress.percent);
});
if (state.files.length === 1) {
const file = state.files[0];
showLoader(`Converting ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
downloadFile(pdfBlob, `${baseName}.pdf`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${file.name} to PDF.`, 'success', () => resetState());
} else {
showLoader('Converting multiple files...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
zip.file(`${baseName}.pdf`, pdfBlob);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${FILETYPE_NAME.toLowerCase()}-to-pdf.zip`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${state.files.length} files to PDF.`, 'success', () => resetState());
}
} catch (err) {
hideLoader();
const message = err instanceof Error ? err.message : 'Unknown error';
console.error(`[${FILETYPE_NAME}ToPDF] Error:`, err);
showAlert('Error', `An error occurred during conversion. Error: ${message}`);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const validFiles = Array.from(files).filter(file => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
return ACCEPTED_EXTENSIONS.includes(ext);
});
if (validFiles.length > 0) {
state.files = [...state.files, ...validFiles];
updateUI();
}
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => handleFileSelect((e.target as HTMLInputElement).files));
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('bg-gray-700'); });
dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); dropZone.classList.remove('bg-gray-700'); });
dropZone.addEventListener('drop', (e) => { e.preventDefault(); dropZone.classList.remove('bg-gray-700'); handleFileSelect(e.dataTransfer?.files ?? null); });
fileInput.addEventListener('click', () => { fileInput.value = ''; });
}
if (addMoreBtn) addMoreBtn.addEventListener('click', () => fileInput.click());
if (clearFilesBtn) clearFilesBtn.addEventListener('click', resetState);
if (processBtn) processBtn.addEventListener('click', convert);
updateUI();
});

View File

@@ -0,0 +1,236 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import {
downloadFile,
readFileAsArrayBuffer,
formatBytes,
} from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { getLibreOfficeConverter, type LoadProgress } from '../utils/libreoffice-loader.js';
document.addEventListener('DOMContentLoaded', () => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const convertOptions = document.getElementById('convert-options');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const processBtn = document.getElementById('process-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!convertOptions) return;
if (state.files.length > 0) {
if (fileDisplayArea) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
}
if (fileControls) fileControls.classList.remove('hidden');
convertOptions.classList.remove('hidden');
} else {
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
if (fileControls) fileControls.classList.add('hidden');
convertOptions.classList.add('hidden');
}
};
const resetState = () => {
state.files = [];
state.pdfDoc = null;
updateUI();
};
const convertToPdf = async () => {
try {
console.log('[Word2PDF] Starting conversion...');
console.log('[Word2PDF] Number of files:', state.files.length);
if (state.files.length === 0) {
showAlert('No Files', 'Please select at least one Word document.');
hideLoader();
return;
}
const converter = getLibreOfficeConverter();
console.log('[Word2PDF] Got converter instance');
// Initialize LibreOffice if not already done
console.log('[Word2PDF] Initializing LibreOffice...');
await converter.initialize((progress: LoadProgress) => {
console.log('[Word2PDF] Init progress:', progress.percent + '%', progress.message);
showLoader(progress.message, progress.percent);
});
console.log('[Word2PDF] LibreOffice initialized successfully!');
if (state.files.length === 1) {
const originalFile = state.files[0];
console.log('[Word2PDF] Converting single file:', originalFile.name);
showLoader('Processing...');
const pdfBlob = await converter.convertToPdf(originalFile);
console.log('[Word2PDF] Conversion complete! PDF size:', pdfBlob.size);
const fileName = originalFile.name.replace(/\.(doc|docx|odt|rtf)$/i, '') + '.pdf';
downloadFile(pdfBlob, fileName);
console.log('[Word2PDF] File downloaded:', fileName);
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${originalFile.name} to PDF.`,
'success',
() => resetState()
);
} else {
console.log('[Word2PDF] Converting multiple files:', state.files.length);
showLoader('Processing...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
console.log(`[Word2PDF] Converting file ${i + 1}/${state.files.length}:`, file.name);
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
console.log(`[Word2PDF] Converted ${file.name}, PDF size:`, pdfBlob.size);
const baseName = file.name.replace(/\.(doc|docx|odt|rtf)$/i, '');
const pdfBuffer = await pdfBlob.arrayBuffer();
zip.file(`${baseName}.pdf`, pdfBuffer);
}
console.log('[Word2PDF] Generating ZIP file...');
const zipBlob = await zip.generateAsync({ type: 'blob' });
console.log('[Word2PDF] ZIP size:', zipBlob.size);
downloadFile(zipBlob, 'word-converted.zip');
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} Word document(s) to PDF.`,
'success',
() => resetState()
);
}
} catch (e: any) {
console.error('[Word2PDF] ERROR:', e);
console.error('[Word2PDF] Error stack:', e.stack);
hideLoader();
showAlert(
'Error',
`An error occurred during conversion. Error: ${e.message}`
);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
state.files = [...state.files, ...Array.from(files)];
updateUI();
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const wordFiles = Array.from(files).filter(f => {
const name = f.name.toLowerCase();
return name.endsWith('.doc') || name.endsWith('.docx') || name.endsWith('.odt') || name.endsWith('.rtf');
});
if (wordFiles.length > 0) {
const dataTransfer = new DataTransfer();
wordFiles.forEach(f => dataTransfer.items.add(f));
handleFileSelect(dataTransfer.files);
}
}
});
// Clear value on click to allow re-selecting the same file
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput.click();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', () => {
resetState();
});
}
if (processBtn) {
processBtn.addEventListener('click', convertToPdf);
}
// Initialize UI state (ensures button is hidden when no files)
updateUI();
});

View File

@@ -0,0 +1,188 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import {
downloadFile,
formatBytes,
} from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { getLibreOfficeConverter, type LoadProgress } from '../utils/libreoffice-loader.js';
const ACCEPTED_EXTENSIONS = ['.wpd'];
const FILETYPE_NAME = 'WPD';
document.addEventListener('DOMContentLoaded', () => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const convertOptions = document.getElementById('convert-options');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const processBtn = document.getElementById('process-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!convertOptions) return;
if (state.files.length > 0) {
if (fileDisplayArea) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
}
if (fileControls) fileControls.classList.remove('hidden');
convertOptions.classList.remove('hidden');
} else {
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
if (fileControls) fileControls.classList.add('hidden');
convertOptions.classList.add('hidden');
}
};
const resetState = () => {
state.files = [];
updateUI();
};
const convert = async () => {
if (state.files.length === 0) {
showAlert('No Files', `Please select at least one ${FILETYPE_NAME} file.`);
return;
}
try {
const converter = getLibreOfficeConverter();
showLoader('Loading engine...');
await converter.initialize((progress: LoadProgress) => {
showLoader(progress.message, progress.percent);
});
if (state.files.length === 1) {
const file = state.files[0];
showLoader(`Converting ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
downloadFile(pdfBlob, `${baseName}.pdf`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${file.name} to PDF.`, 'success', () => resetState());
} else {
showLoader('Converting multiple files...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
zip.file(`${baseName}.pdf`, pdfBlob);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${FILETYPE_NAME.toLowerCase()}-to-pdf.zip`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${state.files.length} files to PDF.`, 'success', () => resetState());
}
} catch (e: any) {
hideLoader();
console.error(`[${FILETYPE_NAME}ToPDF] Error:`, e);
showAlert('Error', `An error occurred during conversion. Error: ${e.message}`);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const validFiles = Array.from(files).filter(file => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
return ACCEPTED_EXTENSIONS.includes(ext);
});
if (validFiles.length > 0) {
state.files = [...state.files, ...validFiles];
updateUI();
}
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => fileInput.click());
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', resetState);
}
if (processBtn) {
processBtn.addEventListener('click', convert);
}
updateUI();
});

View File

@@ -0,0 +1,188 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import {
downloadFile,
formatBytes,
} from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { getLibreOfficeConverter, type LoadProgress } from '../utils/libreoffice-loader.js';
const ACCEPTED_EXTENSIONS = ['.wps'];
const FILETYPE_NAME = 'WPS';
document.addEventListener('DOMContentLoaded', () => {
state.files = [];
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const convertOptions = document.getElementById('convert-options');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const processBtn = document.getElementById('process-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!convertOptions) return;
if (state.files.length > 0) {
if (fileDisplayArea) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
}
if (fileControls) fileControls.classList.remove('hidden');
convertOptions.classList.remove('hidden');
} else {
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
if (fileControls) fileControls.classList.add('hidden');
convertOptions.classList.add('hidden');
}
};
const resetState = () => {
state.files = [];
updateUI();
};
const convert = async () => {
if (state.files.length === 0) {
showAlert('No Files', `Please select at least one ${FILETYPE_NAME} file.`);
return;
}
try {
const converter = getLibreOfficeConverter();
showLoader('Loading engine...');
await converter.initialize((progress: LoadProgress) => {
showLoader(progress.message, progress.percent);
});
if (state.files.length === 1) {
const file = state.files[0];
showLoader(`Converting ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
downloadFile(pdfBlob, `${baseName}.pdf`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${file.name} to PDF.`, 'success', () => resetState());
} else {
showLoader('Converting multiple files...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const pdfBlob = await converter.convertToPdf(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
zip.file(`${baseName}.pdf`, pdfBlob);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${FILETYPE_NAME.toLowerCase()}-to-pdf.zip`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${state.files.length} files to PDF.`, 'success', () => resetState());
}
} catch (e: any) {
hideLoader();
console.error(`[${FILETYPE_NAME}ToPDF] Error:`, e);
showAlert('Error', `An error occurred during conversion. Error: ${e.message}`);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const validFiles = Array.from(files).filter(file => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
return ACCEPTED_EXTENSIONS.includes(ext);
});
if (validFiles.length > 0) {
state.files = [...state.files, ...validFiles];
updateUI();
}
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => fileInput.click());
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', resetState);
}
if (processBtn) {
processBtn.addEventListener('click', convert);
}
updateUI();
});

View File

@@ -0,0 +1,181 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import {
downloadFile,
formatBytes,
} from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { convertXmlToPdf } from '../utils/xml-to-pdf.js';
const ACCEPTED_EXTENSIONS = ['.xml'];
const FILETYPE_NAME = 'XML';
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!fileDisplayArea || !processBtn || !fileControls) return;
if (state.files.length > 0) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
fileControls.classList.remove('hidden');
processBtn.classList.remove('hidden');
} else {
fileDisplayArea.innerHTML = '';
fileControls.classList.add('hidden');
processBtn.classList.add('hidden');
}
};
const resetState = () => {
state.files = [];
updateUI();
};
const convert = async () => {
if (state.files.length === 0) {
showAlert('No Files', `Please select at least one ${FILETYPE_NAME} file.`);
return;
}
try {
if (state.files.length === 1) {
const file = state.files[0];
const pdfBlob = await convertXmlToPdf(file, {
onProgress: (percent, message) => {
showLoader(message, percent);
}
});
const baseName = file.name.replace(/\.[^/.]+$/, '');
downloadFile(pdfBlob, `${baseName}.pdf`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${file.name} to PDF.`, 'success', () => resetState());
} else {
showLoader('Converting multiple files...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
const pdfBlob = await convertXmlToPdf(file, {
onProgress: (percent, message) => {
showLoader(`File ${i + 1}/${state.files.length}: ${message}`, percent);
}
});
const baseName = file.name.replace(/\.[^/.]+$/, '');
zip.file(`${baseName}.pdf`, pdfBlob);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${FILETYPE_NAME.toLowerCase()}-to-pdf.zip`);
hideLoader();
showAlert('Conversion Complete', `Successfully converted ${state.files.length} files to PDF.`, 'success', () => resetState());
}
} catch (err) {
hideLoader();
const message = err instanceof Error ? err.message : 'Unknown error';
console.error(`[${FILETYPE_NAME}ToPDF] Error:`, err);
showAlert('Error', `An error occurred during conversion. Error: ${message}`);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const validFiles = Array.from(files).filter(file => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
return ACCEPTED_EXTENSIONS.includes(ext);
});
if (validFiles.length > 0) {
state.files = [...state.files, ...validFiles];
updateUI();
}
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files ?? null);
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => fileInput.click());
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', resetState);
}
if (processBtn) {
processBtn.addEventListener('click', convert);
}
});

View File

@@ -0,0 +1,202 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
const FILETYPE = 'xps';
const EXTENSIONS = ['.xps', '.oxps'];
const TOOL_NAME = 'XPS';
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const updateUI = async () => {
if (!fileDisplayArea || !processBtn || !fileControls) return;
if (state.files.length > 0) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
fileControls.classList.remove('hidden');
processBtn.classList.remove('hidden');
(processBtn as HTMLButtonElement).disabled = false;
} else {
fileDisplayArea.innerHTML = '';
fileControls.classList.add('hidden');
processBtn.classList.add('hidden');
(processBtn as HTMLButtonElement).disabled = true;
}
};
const resetState = () => {
state.files = [];
state.pdfDoc = null;
updateUI();
};
const convertToPdf = async () => {
try {
if (state.files.length === 0) {
showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`);
return;
}
showLoader('Loading engine...');
const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
await pymupdf.load();
if (state.files.length === 1) {
const originalFile = state.files[0];
showLoader(`Converting ${originalFile.name}...`);
const pdfBlob = await pymupdf.convertToPdf(originalFile, { filetype: FILETYPE });
const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf';
downloadFile(pdfBlob, fileName);
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${originalFile.name} to PDF.`,
'success',
() => resetState()
);
} else {
showLoader('Converting files...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const pdfBlob = await pymupdf.convertToPdf(file, { filetype: FILETYPE });
const baseName = file.name.replace(/\.[^.]+$/, '');
const pdfBuffer = await pdfBlob.arrayBuffer();
zip.file(`${baseName}.pdf`, pdfBuffer);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${FILETYPE}-converted.zip`);
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`,
'success',
() => resetState()
);
}
} catch (e: any) {
console.error(`[${TOOL_NAME}2PDF] ERROR:`, e);
hideLoader();
showAlert('Error', `An error occurred during conversion. Error: ${e.message}`);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
state.files = [...state.files, ...Array.from(files)];
updateUI();
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const validFiles = Array.from(files).filter(f => {
const name = f.name.toLowerCase();
return EXTENSIONS.some(ext => name.endsWith(ext));
});
if (validFiles.length > 0) {
const dataTransfer = new DataTransfer();
validFiles.forEach(f => dataTransfer.items.add(f));
handleFileSelect(dataTransfer.files);
}
}
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput.click();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', () => {
resetState();
});
}
if (processBtn) {
processBtn.addEventListener('click', convertToPdf);
}
});

View File

@@ -1,20 +1,31 @@
import { categories } from './config/tools.js';
import { dom, switchView, hideAlert, showLoader, hideLoader, showAlert } from './ui.js';
import { state, resetState } from './state.js';
import { dom, switchView, hideAlert } from './ui.js';
import { ShortcutsManager } from './logic/shortcuts.js';
import { createIcons, icons } from 'lucide';
import '@phosphor-icons/web/regular';
import * as pdfjsLib from 'pdfjs-dist';
import '../css/styles.css';
import { formatShortcutDisplay, formatStars } from './utils/helpers.js';
import { APP_VERSION, injectVersion } from '../version.js';
import { initI18n, applyTranslations, rewriteLinks, injectLanguageSwitcher, createLanguageSwitcher, t } from './i18n/index.js';
import { APP_VERSION } from '../version.js';
import {
initI18n,
applyTranslations,
rewriteLinks,
injectLanguageSwitcher,
createLanguageSwitcher,
t,
} from './i18n/index.js';
import { startBackgroundPreload } from './utils/wasm-preloader.js';
const init = async () => {
await initI18n();
injectLanguageSwitcher();
applyTranslations();
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
if (__SIMPLE_MODE__) {
const hideBrandingSections = () => {
const nav = document.querySelector('nav');
@@ -44,7 +55,9 @@ const init = async () => {
heroSection.style.display = 'none';
}
const githubLink = document.querySelector('a[href*="github.com/alam00000/bentopdf"]');
const githubLink = document.querySelector(
'a[href*="github.com/alam00000/bentopdf"]'
);
if (githubLink) {
(githubLink as HTMLElement).style.display = 'none';
}
@@ -79,7 +92,9 @@ const init = async () => {
}
// Hide "Used by companies" section
const usedBySection = document.querySelector('.hide-section') as HTMLElement;
const usedBySection = document.querySelector(
'.hide-section'
) as HTMLElement;
if (usedBySection) {
usedBySection.style.display = 'none';
}
@@ -100,7 +115,7 @@ const init = async () => {
<span class="text-white font-bold text-lg">BentoPDF</span>
</div>
<p class="text-gray-400 text-sm">
&copy; 2025 BentoPDF. All rights reserved.
&copy; 2026 BentoPDF. All rights reserved.
</p>
<p class="text-gray-500 text-xs mt-2">
Version <span id="app-version-simple">${APP_VERSION}</span>
@@ -112,7 +127,9 @@ const init = async () => {
`;
document.body.appendChild(simpleFooter);
const langContainer = simpleFooter.querySelector('#simple-mode-lang-switcher');
const langContainer = simpleFooter.querySelector(
'#simple-mode-lang-switcher'
);
if (langContainer) {
const switcher = createLanguageSwitcher();
const dropdown = switcher.querySelector('div[role="menu"]');
@@ -165,13 +182,14 @@ const init = async () => {
if (shortcutSettingsBtn) shortcutSettingsBtn.style.display = 'none';
} else {
if (keyboardShortcutBtn) {
keyboardShortcutBtn.textContent = navigator.userAgent.toUpperCase().includes('MAC')
keyboardShortcutBtn.textContent = navigator.userAgent
.toUpperCase()
.includes('MAC')
? '⌘ + K'
: 'Ctrl + K';
}
}
const categoryTranslationKeys: Record<string, string> = {
'Popular Tools': 'tools:categories.popularTools',
'Edit & Annotate': 'tools:categories.editAnnotate',
@@ -207,7 +225,7 @@ const init = async () => {
'PDF Form Filler': 'tools:pdfFormFiller',
'Create PDF Form': 'tools:createPdfForm',
'Remove Blank Pages': 'tools:removeBlankPages',
'Image to PDF': 'tools:imageToPdf',
'Images to PDF': 'tools:imageToPdf',
'PNG to PDF': 'tools:pngToPdf',
'WebP to PDF': 'tools:webpToPdf',
'SVG to PDF': 'tools:svgToPdf',
@@ -233,6 +251,7 @@ const init = async () => {
'Add Blank Page': 'tools:addBlankPage',
'Reverse Pages': 'tools:reversePages',
'Rotate PDF': 'tools:rotatePdf',
'Rotate by Custom Degrees': 'tools:rotateCustom',
'N-Up PDF': 'tools:nUpPdf',
'Combine to Single Page': 'tools:combineToSinglePage',
'View Metadata': 'tools:viewMetadata',
@@ -262,7 +281,8 @@ const init = async () => {
categoryGroup.className = 'category-group col-span-full';
const title = document.createElement('h2');
title.className = 'text-xl font-bold text-indigo-400 mb-4 mt-8 first:mt-0 text-white';
title.className =
'text-xl font-bold text-indigo-400 mb-4 mt-8 first:mt-0 text-white';
const categoryKey = categoryTranslationKeys[category.name];
title.textContent = categoryKey ? t(categoryKey) : category.name;
@@ -287,7 +307,12 @@ const init = async () => {
const icon = document.createElement('i');
icon.className = 'w-10 h-10 mb-3 text-indigo-400';
icon.setAttribute('data-lucide', tool.icon);
if (tool.icon.startsWith('ph-')) {
icon.className = `ph ${tool.icon} text-4xl mb-3 text-indigo-400`;
} else {
icon.setAttribute('data-lucide', tool.icon);
}
const toolName = document.createElement('h3');
toolName.className = 'font-semibold text-white';
@@ -299,7 +324,9 @@ const init = async () => {
if (tool.subtitle) {
const toolSubtitle = document.createElement('p');
toolSubtitle.className = 'text-xs text-gray-400 mt-1 px-2';
toolSubtitle.textContent = toolKey ? t(`${toolKey}.subtitle`) : tool.subtitle;
toolSubtitle.textContent = toolKey
? t(`${toolKey}.subtitle`)
: tool.subtitle;
toolCard.appendChild(toolSubtitle);
}
@@ -313,46 +340,73 @@ const init = async () => {
const searchBar = document.getElementById('search-bar');
const categoryGroups = dom.toolGrid.querySelectorAll('.category-group');
const fuzzyMatch = (searchTerm: string, targetText: string): boolean => {
if (!searchTerm) return true;
let searchIndex = 0;
let targetIndex = 0;
while (searchIndex < searchTerm.length && targetIndex < targetText.length) {
if (searchTerm[searchIndex] === targetText[targetIndex]) {
searchIndex++;
}
targetIndex++;
}
return searchIndex === searchTerm.length;
};
const searchResultsContainer = document.createElement('div');
searchResultsContainer.id = 'search-results';
searchResultsContainer.className =
'hidden grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6 col-span-full';
dom.toolGrid.insertBefore(searchResultsContainer, dom.toolGrid.firstChild);
searchBar.addEventListener('input', () => {
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
const searchTerm = searchBar.value.toLowerCase().trim();
if (!searchTerm) {
searchResultsContainer.classList.add('hidden');
searchResultsContainer.innerHTML = '';
categoryGroups.forEach((group) => {
(group as HTMLElement).style.display = '';
const toolCards = group.querySelectorAll('.tool-card');
toolCards.forEach((card) => {
(card as HTMLElement).style.display = '';
});
});
return;
}
categoryGroups.forEach((group) => {
const toolCards = group.querySelectorAll('.tool-card');
let visibleToolsInCategory = 0;
(group as HTMLElement).style.display = 'none';
});
searchResultsContainer.innerHTML = '';
searchResultsContainer.classList.remove('hidden');
const seenToolIds = new Set<string>();
const allTools: HTMLElement[] = [];
categoryGroups.forEach((group) => {
const toolCards = Array.from(group.querySelectorAll('.tool-card'));
toolCards.forEach((card) => {
const toolName = card.querySelector('h3').textContent.toLowerCase();
const toolSubtitle =
card.querySelector('p')?.textContent.toLowerCase() || '';
const toolName = (
card.querySelector('h3')?.textContent || ''
).toLowerCase();
const toolSubtitle = (
card.querySelector('p')?.textContent || ''
).toLowerCase();
const toolHref =
(card as HTMLAnchorElement).href ||
(card as HTMLElement).dataset.toolId ||
'';
const toolId =
toolHref.split('/').pop()?.replace('.html', '') || toolName;
const isMatch =
fuzzyMatch(searchTerm, toolName) || fuzzyMatch(searchTerm, toolSubtitle);
toolName.includes(searchTerm) || toolSubtitle.includes(searchTerm);
const isDuplicate = seenToolIds.has(toolId);
card.classList.toggle('hidden', !isMatch);
if (isMatch) {
visibleToolsInCategory++;
if (isMatch && !isDuplicate) {
seenToolIds.add(toolId);
allTools.push(card.cloneNode(true) as HTMLElement);
}
});
group.classList.toggle('hidden', visibleToolsInCategory === 0);
});
allTools.forEach((tool) => {
searchResultsContainer.appendChild(tool);
});
createIcons({ icons });
});
window.addEventListener('keydown', function (e) {
@@ -403,31 +457,32 @@ const init = async () => {
createIcons({ icons });
console.log('Please share our tool and share the love!');
// Start background WASM preloading on all pages
startBackgroundPreload();
const githubStarsElements = [
document.getElementById('github-stars-desktop'),
document.getElementById('github-stars-mobile')
document.getElementById('github-stars-mobile'),
];
if (githubStarsElements.some(el => el) && !__SIMPLE_MODE__) {
if (githubStarsElements.some((el) => el) && !__SIMPLE_MODE__) {
fetch('https://api.github.com/repos/alam00000/bentopdf')
.then((response) => response.json())
.then((data) => {
if (data.stargazers_count !== undefined) {
const formattedStars = formatStars(data.stargazers_count);
githubStarsElements.forEach(el => {
githubStarsElements.forEach((el) => {
if (el) el.textContent = formattedStars;
});
}
})
.catch(() => {
githubStarsElements.forEach(el => {
githubStarsElements.forEach((el) => {
if (el) el.textContent = '-';
});
});
}
// Initialize Shortcuts System
ShortcutsManager.init();
@@ -435,9 +490,13 @@ const init = async () => {
const shortcutsTabBtn = document.getElementById('shortcuts-tab-btn');
const preferencesTabBtn = document.getElementById('preferences-tab-btn');
const shortcutsTabContent = document.getElementById('shortcuts-tab-content');
const preferencesTabContent = document.getElementById('preferences-tab-content');
const preferencesTabContent = document.getElementById(
'preferences-tab-content'
);
const shortcutsTabFooter = document.getElementById('shortcuts-tab-footer');
const preferencesTabFooter = document.getElementById('preferences-tab-footer');
const preferencesTabFooter = document.getElementById(
'preferences-tab-footer'
);
const resetShortcutsBtn = document.getElementById('reset-shortcuts-btn');
if (shortcutsTabBtn && preferencesTabBtn) {
@@ -467,11 +526,12 @@ const init = async () => {
}
// Full-width toggle functionality
const fullWidthToggle = document.getElementById('full-width-toggle') as HTMLInputElement;
const fullWidthToggle = document.getElementById(
'full-width-toggle'
) as HTMLInputElement;
const toolInterface = document.getElementById('tool-interface');
// Load saved preference
const savedFullWidth = localStorage.getItem('fullWidthMode') === 'true';
const savedFullWidth = localStorage.getItem('fullWidthMode') !== 'false';
if (fullWidthToggle) {
fullWidthToggle.checked = savedFullWidth;
applyFullWidthMode(savedFullWidth);
@@ -493,7 +553,10 @@ const init = async () => {
uploader.classList.remove('max-w-2xl', 'max-w-5xl');
} else {
// Restore original max-width (most are max-w-2xl, add-stamps is max-w-5xl)
if (!uploader.classList.contains('max-w-2xl') && !uploader.classList.contains('max-w-5xl')) {
if (
!uploader.classList.contains('max-w-2xl') &&
!uploader.classList.contains('max-w-5xl')
) {
uploader.classList.add('max-w-2xl');
}
}
@@ -614,26 +677,36 @@ const init = async () => {
}
// Reserved shortcuts that commonly conflict with browser/OS functions
const RESERVED_SHORTCUTS: Record<string, { mac?: string; windows?: string }> = {
'mod+w': { mac: 'Closes tab', windows: 'Closes tab' },
'mod+t': { mac: 'Opens new tab', windows: 'Opens new tab' },
'mod+n': { mac: 'Opens new window', windows: 'Opens new window' },
'mod+shift+n': { mac: 'Opens incognito window', windows: 'Opens incognito window' },
'mod+q': { mac: 'Quits application (cannot be overridden)' },
'mod+m': { mac: 'Minimizes window' },
'mod+h': { mac: 'Hides window' },
'mod+r': { mac: 'Reloads page', windows: 'Reloads page' },
'mod+shift+r': { mac: 'Hard reloads page', windows: 'Hard reloads page' },
'mod+l': { mac: 'Focuses address bar', windows: 'Focuses address bar' },
'mod+d': { mac: 'Bookmarks page', windows: 'Bookmarks page' },
'mod+shift+t': { mac: 'Reopens closed tab', windows: 'Reopens closed tab' },
'mod+shift+w': { mac: 'Closes window', windows: 'Closes window' },
'mod+tab': { mac: 'Switches tabs', windows: 'Switches apps' },
'alt+f4': { windows: 'Closes window' },
'ctrl+tab': { mac: 'Switches tabs', windows: 'Switches tabs' },
};
const RESERVED_SHORTCUTS: Record<string, { mac?: string; windows?: string }> =
{
'mod+w': { mac: 'Closes tab', windows: 'Closes tab' },
'mod+t': { mac: 'Opens new tab', windows: 'Opens new tab' },
'mod+n': { mac: 'Opens new window', windows: 'Opens new window' },
'mod+shift+n': {
mac: 'Opens incognito window',
windows: 'Opens incognito window',
},
'mod+q': { mac: 'Quits application (cannot be overridden)' },
'mod+m': { mac: 'Minimizes window' },
'mod+h': { mac: 'Hides window' },
'mod+r': { mac: 'Reloads page', windows: 'Reloads page' },
'mod+shift+r': { mac: 'Hard reloads page', windows: 'Hard reloads page' },
'mod+l': { mac: 'Focuses address bar', windows: 'Focuses address bar' },
'mod+d': { mac: 'Bookmarks page', windows: 'Bookmarks page' },
'mod+shift+t': {
mac: 'Reopens closed tab',
windows: 'Reopens closed tab',
},
'mod+shift+w': { mac: 'Closes window', windows: 'Closes window' },
'mod+tab': { mac: 'Switches tabs', windows: 'Switches apps' },
'alt+f4': { windows: 'Closes window' },
'ctrl+tab': { mac: 'Switches tabs', windows: 'Switches tabs' },
};
function getReservedShortcutWarning(combo: string, isMac: boolean): string | null {
function getReservedShortcutWarning(
combo: string,
isMac: boolean
): string | null {
const reserved = RESERVED_SHORTCUTS[combo];
if (!reserved) return null;
@@ -643,9 +716,19 @@ const init = async () => {
return description;
}
function showWarningModal(title: string, message: string, confirmMode: boolean = true): Promise<boolean> {
function showWarningModal(
title: string,
message: string,
confirmMode: boolean = true
): Promise<boolean> {
return new Promise((resolve) => {
if (!dom.warningModal || !dom.warningTitle || !dom.warningMessage || !dom.warningCancelBtn || !dom.warningConfirmBtn) {
if (
!dom.warningModal ||
!dom.warningTitle ||
!dom.warningMessage ||
!dom.warningCancelBtn ||
!dom.warningConfirmBtn
) {
resolve(confirmMode ? confirm(message) : (alert(message), true));
return;
}
@@ -684,15 +767,19 @@ const init = async () => {
dom.warningCancelBtn.addEventListener('click', handleCancel);
// Close on backdrop click
dom.warningModal.addEventListener('click', (e) => {
if (e.target === dom.warningModal) {
if (confirmMode) {
handleCancel();
} else {
handleConfirm();
dom.warningModal.addEventListener(
'click',
(e) => {
if (e.target === dom.warningModal) {
if (confirmMode) {
handleCancel();
} else {
handleConfirm();
}
}
}
}, { once: true });
},
{ once: true }
);
});
}
@@ -711,14 +798,15 @@ const init = async () => {
const allShortcuts = ShortcutsManager.getAllShortcuts();
const isMac = navigator.userAgent.toUpperCase().includes('MAC');
const allTools = categories.flatMap(c => c.tools);
const allTools = categories.flatMap((c) => c.tools);
categories.forEach(category => {
categories.forEach((category) => {
const section = document.createElement('div');
section.className = 'category-section mb-6 last:mb-0';
const header = document.createElement('h3');
header.className = 'text-gray-400 text-xs font-bold uppercase tracking-wider mb-3 pl-1';
header.className =
'text-gray-400 text-xs font-bold uppercase tracking-wider mb-3 pl-1';
// Translate category name
const categoryKey = categoryTranslationKeys[category.name];
header.textContent = categoryKey ? t(categoryKey) : category.name;
@@ -730,13 +818,14 @@ const init = async () => {
let hasTools = false;
category.tools.forEach(tool => {
category.tools.forEach((tool) => {
hasTools = true;
const toolId = getToolId(tool);
const currentShortcut = allShortcuts.get(toolId) || '';
const item = document.createElement('div');
item.className = 'shortcut-item flex items-center justify-between p-3 bg-gray-900 rounded-lg border border-gray-700 hover:border-gray-600 transition-colors';
item.className =
'shortcut-item flex items-center justify-between p-3 bg-gray-900 rounded-lg border border-gray-700 hover:border-gray-600 transition-colors';
const left = document.createElement('div');
left.className = 'flex items-center gap-3';
@@ -757,13 +846,15 @@ const init = async () => {
const input = document.createElement('input');
input.type = 'text';
input.className = 'shortcut-input w-32 bg-gray-800 border border-gray-600 text-white text-center text-sm rounded px-2 py-1 focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition-all';
input.className =
'shortcut-input w-32 bg-gray-800 border border-gray-600 text-white text-center text-sm rounded px-2 py-1 focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition-all';
input.placeholder = t('settings.clickToSet');
input.value = formatShortcutDisplay(currentShortcut, isMac);
input.readOnly = true;
const clearBtn = document.createElement('button');
clearBtn.className = 'absolute -right-2 -top-2 bg-gray-700 hover:bg-red-600 text-white rounded-full p-0.5 hidden group-hover:block shadow-sm';
clearBtn.className =
'absolute -right-2 -top-2 bg-gray-700 hover:bg-red-600 text-white rounded-full p-0.5 hidden group-hover:block shadow-sm';
clearBtn.innerHTML = '<i data-lucide="x" class="w-3 h-3"></i>';
if (currentShortcut) {
right.classList.add('group');
@@ -812,7 +903,10 @@ const init = async () => {
// Ignore dead keys (used for accented characters on Mac with Option key)
if (isDeadKey) {
input.value = formatShortcutDisplay(ShortcutsManager.getShortcut(toolId) || '', isMac);
input.value = formatShortcutDisplay(
ShortcutsManager.getShortcut(toolId) || '',
isMac
);
return;
}
@@ -828,22 +922,31 @@ const init = async () => {
const existingToolId = ShortcutsManager.findToolByShortcut(combo);
if (existingToolId && existingToolId !== toolId) {
const existingTool = allTools.find(t => getToolId(t) === existingToolId);
const existingTool = allTools.find(
(t) => getToolId(t) === existingToolId
);
const existingToolName = existingTool?.name || existingToolId;
const displayCombo = formatShortcutDisplay(combo, isMac);
const existingToolKey = existingTool ? toolTranslationKeys[existingTool.name] : null;
const translatedToolName = existingToolKey ? t(`${existingToolKey}.name`) : existingToolName;
const existingToolKey = existingTool
? toolTranslationKeys[existingTool.name]
: null;
const translatedToolName = existingToolKey
? t(`${existingToolKey}.name`)
: existingToolName;
await showWarningModal(
t('settings.warnings.alreadyInUse'),
`<strong>${displayCombo}</strong> ${t('settings.warnings.assignedTo')}<br><br>` +
`<em>"${translatedToolName}"</em><br><br>` +
t('settings.warnings.chooseDifferent'),
`<em>"${translatedToolName}"</em><br><br>` +
t('settings.warnings.chooseDifferent'),
false
);
input.value = formatShortcutDisplay(ShortcutsManager.getShortcut(toolId) || '', isMac);
input.value = formatShortcutDisplay(
ShortcutsManager.getShortcut(toolId) || '',
isMac
);
input.classList.remove('border-indigo-500', 'text-indigo-400');
input.blur();
return;
@@ -855,14 +958,17 @@ const init = async () => {
const shouldProceed = await showWarningModal(
t('settings.warnings.reserved'),
`<strong>${displayCombo}</strong> ${t('settings.warnings.commonlyUsed')}<br><br>` +
`"<em>${reservedWarning}</em>"<br><br>` +
`${t('settings.warnings.unreliable')}<br><br>` +
t('settings.warnings.useAnyway')
`"<em>${reservedWarning}</em>"<br><br>` +
`${t('settings.warnings.unreliable')}<br><br>` +
t('settings.warnings.useAnyway')
);
if (!shouldProceed) {
// Revert display
input.value = formatShortcutDisplay(ShortcutsManager.getShortcut(toolId) || '', isMac);
input.value = formatShortcutDisplay(
ShortcutsManager.getShortcut(toolId) || '',
isMac
);
input.classList.remove('border-indigo-500', 'text-indigo-400');
input.blur();
return;
@@ -889,7 +995,10 @@ const init = async () => {
};
input.onblur = () => {
input.value = formatShortcutDisplay(ShortcutsManager.getShortcut(toolId) || '', isMac);
input.value = formatShortcutDisplay(
ShortcutsManager.getShortcut(toolId) || '',
isMac
);
input.classList.remove('border-indigo-500', 'text-indigo-400');
};
@@ -928,7 +1037,7 @@ const init = async () => {
scrollToTopBtn.addEventListener('click', () => {
window.scrollTo({
top: 0,
behavior: 'instant'
behavior: 'instant',
});
});
}

55
src/js/sw-register.ts Normal file
View File

@@ -0,0 +1,55 @@
/**
* Service Worker Registration
* Registers the service worker to enable offline caching
*
* Note: Service Worker is disabled in development mode to prevent
* conflicts with Vite's HMR (Hot Module Replacement)
*/
// Skip service worker registration in development mode
const isDevelopment = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.port !== '';
if (isDevelopment) {
console.log('[Dev Mode] Service Worker registration skipped in development');
console.log('Service Worker will be active in production builds');
} else if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
const swPath = `${import.meta.env.BASE_URL}sw.js`;
console.log('[SW] Registering Service Worker at:', swPath);
navigator.serviceWorker
.register(swPath)
.then((registration) => {
console.log('[SW] Service Worker registered successfully:', registration.scope);
setInterval(() => {
registration.update();
}, 24 * 60 * 60 * 1000);
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
console.log('[SW] New version available! Reload to update.');
if (confirm('A new version of BentoPDF is available. Reload to update?')) {
newWorker.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
}
}
});
}
});
})
.catch((error) => {
console.error('[SW] Service Worker registration failed:', error);
});
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('[SW] New service worker activated, reloading...');
window.location.reload();
});
});
}

View File

@@ -0,0 +1,6 @@
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
export interface AddBlankPageState {
file: File | null;
pdfDoc: PDFLibDocument | null;
}

View File

@@ -0,0 +1,6 @@
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
export interface AddWatermarkState {
file: File | null;
pdfDoc: PDFLibDocument | null;
}

View File

@@ -0,0 +1,5 @@
export interface AlternateMergeState {
files: File[];
pdfBytes: Map<string, ArrayBuffer>;
pdfDocs: Map<string, any>;
}

View File

@@ -0,0 +1,7 @@
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
export interface AddAttachmentState {
file: File | null;
pdfDoc: PDFLibDocument | null;
attachments: File[];
}

View File

@@ -0,0 +1,6 @@
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
export interface BackgroundColorState {
file: File | null;
pdfDoc: PDFLibDocument | null;
}

View File

@@ -0,0 +1,3 @@
export interface ChangePermissionsState {
file: File | null;
}

View File

@@ -0,0 +1,6 @@
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
export interface CombineSinglePageState {
file: File | null;
pdfDoc: PDFLibDocument | null;
}

View File

@@ -0,0 +1,9 @@
import * as pdfjsLib from 'pdfjs-dist';
export interface CompareState {
pdfDoc1: pdfjsLib.PDFDocumentProxy | null;
pdfDoc2: pdfjsLib.PDFDocumentProxy | null;
currentPage: number;
viewMode: 'overlay' | 'side-by-side';
isSyncScroll: boolean;
}

View File

@@ -0,0 +1,8 @@
export interface CropperState {
pdfDoc: any;
currentPageNum: number;
cropper: any;
originalPdfBytes: ArrayBuffer | null;
pageCrops: Record<number, any>;
file: File | null;
}

View File

@@ -0,0 +1,3 @@
export interface DecryptPdfState {
file: File | null;
}

Some files were not shown because too many files have changed in this diff Show More