From b2e69786490021f8be32a96435f93fa7f995fc8e Mon Sep 17 00:00:00 2001 From: Alexandre Reis Date: Wed, 17 Dec 2025 23:16:58 -0300 Subject: [PATCH 01/73] feat(i18n): add pt-BR translation --- public/locales/pt/common.json | 318 ++++++++++++++++++++++++++++++++++ public/locales/pt/tools.json | 282 ++++++++++++++++++++++++++++++ 2 files changed, 600 insertions(+) create mode 100644 public/locales/pt/common.json create mode 100644 public/locales/pt/tools.json diff --git a/public/locales/pt/common.json b/public/locales/pt/common.json new file mode 100644 index 0000000..3d7dc60 --- /dev/null +++ b/public/locales/pt/common.json @@ -0,0 +1,318 @@ +{ + "nav": { + "home": "Início", + "about": "Sobre", + "contact": "Contato", + "licensing": "Licenciamento", + "allTools": "Todas as Ferramentas", + "openMainMenu": "Abrir menu principal", + "language": "Idioma" + }, + "hero": { + "title": "O", + "pdfToolkit": "Kit de Ferramentas PDF", + "builtForPrivacy": "feito para sua privacidade", + "noSignups": "Sem Cadastros", + "unlimitedUse": "Uso Ilimitado", + "worksOffline": "Funciona Offline", + "startUsing": "Comece a Usar Agora" + }, + "usedBy": { + "title": "Usado por empresas e pessoas que trabalham em" + }, + "features": { + "title": "Por que escolher o", + "bentoPdf": "BentoPDF?", + "noSignup": { + "title": "Sem Cadastro", + "description": "Comece instantaneamente, sem contas ou e-mails." + }, + "noUploads": { + "title": "Sem Uploads", + "description": "100% no navegador, seus arquivos nunca saem do seu dispositivo." + }, + "foreverFree": { + "title": "Sempre Grátis", + "description": "Todas as ferramentas, sem testes ou assinaturas." + }, + "noLimits": { + "title": "Sem Limites", + "description": "Use o quanto quiser, sem taxas escondidas." + }, + "batchProcessing": { + "title": "Processamento em Lote", + "description": "Gerencie vários PDFs de uma só vez." + }, + "lightningFast": { + "title": "Super Rápido", + "description": "Processe PDFs instantaneamente, sem esperas ou atrasos." + } + }, + "tools": { + "title": "Comece com as", + "toolsLabel": "Ferramentas", + "subtitle": "Clique em uma ferramenta para abrir o seletor de arquivos", + "searchPlaceholder": "Buscar ferramenta (ex: 'dividir', 'organizar'...)", + "backToTools": "Voltar para Ferramentas" + }, + "upload": { + "clickToSelect": "Clique para selecionar um arquivo", + "orDragAndDrop": "ou arraste e solte", + "pdfOrImages": "PDFs ou Imagens", + "filesNeverLeave": "Seus arquivos nunca saem do seu dispositivo.", + "addMore": "Adicionar Mais Arquivos", + "clearAll": "Limpar Tudo" + }, + "loader": { + "processing": "Processando..." + }, + "alert": { + "title": "Alerta", + "ok": "OK" + }, + "preview": { + "title": "Visualização do Documento", + "downloadAsPdf": "Baixar como PDF", + "close": "Fechar" + }, + "settings": { + "title": "Configurações", + "shortcuts": "Atalhos", + "preferences": "Preferências", + "displayPreferences": "Preferências de Exibição", + "searchShortcuts": "Buscar atalhos...", + "shortcutsInfo": "Mantenha as teclas pressionadas para definir um atalho. As alterações são salvas automaticamente.", + "shortcutsWarning": "⚠️ Evite atalhos comuns do navegador (Cmd/Ctrl+W, T, N etc.), pois podem não funcionar corretamente.", + "import": "Importar", + "export": "Exportar", + "resetToDefaults": "Restaurar Padrões", + "fullWidthMode": "Modo Largura Total", + "fullWidthDescription": "Usa toda a largura da tela para as ferramentas em vez de um container centralizado", + "settingsAutoSaved": "As configurações são salvas automaticamente", + "clickToSet": "Clique para definir", + "pressKeys": "Pressione as teclas...", + "warnings": { + "alreadyInUse": "Atalho Já em Uso", + "assignedTo": "já está atribuído a:", + "chooseDifferent": "Por favor, escolha um atalho diferente.", + "reserved": "Aviso de Atalho Reservado", + "commonlyUsed": "é comumente usado para:", + "unreliable": "Este atalho pode não funcionar bem ou conflitar com o navegador/sistema.", + "useAnyway": "Deseja usar mesmo assim?", + "resetTitle": "Redefinir Atalhos", + "resetMessage": "Tem certeza que deseja redefinir todos os atalhos?

Esta ação não pode ser desfeita.", + "importSuccessTitle": "Importação Concluída", + "importSuccessMessage": "Atalhos importados com sucesso!", + "importFailTitle": "Falha na Importação", + "importFailMessage": "Falha ao importar atalhos. Formato de arquivo inválido." + } + }, + "warning": { + "title": "Aviso", + "cancel": "Cancelar", + "proceed": "Prosseguir" + }, + "compliance": { + "title": "Seus dados nunca saem do seu dispositivo", + "weKeep": "Mantemos", + "yourInfoSafe": "suas informações seguras", + "byFollowingStandards": "seguindo padrões globais de segurança.", + "processingLocal": "Todo o processamento acontece localmente no seu dispositivo.", + "gdpr": { + "title": "Conformidade GDPR", + "description": "Protege os dados pessoais e a privacidade de indivíduos na União Europeia." + }, + "ccpa": { + "title": "Conformidade CCPA", + "description": "Dá aos residentes da Califórnia direitos sobre como suas informações pessoais são coletadas e usadas." + }, + "hipaa": { + "title": "Conformidade HIPAA", + "description": "Estabelece salvaguardas para o tratamento de informações de saúde sensíveis nos Estados Unidos." + } + }, + "faq": { + "title": "Perguntas", + "questions": "Frequentes", + "isFree": { + "question": "O BentoPDF é realmente grátis?", + "answer": "Sim, com certeza. Todas as ferramentas do BentoPDF são 100% gratuitas, sem limites de arquivos, sem cadastros e sem marcas d'água. Acreditamos que todos merecem acesso a ferramentas PDF poderosas sem barreiras financeiras." + }, + "areFilesSecure": { + "question": "Meus arquivos estão seguros? Onde são processados?", + "answer": "Seus arquivos estão o mais seguros possível porque nunca saem do seu computador. Todo o processamento ocorre diretamente no seu navegador (client-side). Nunca fazemos upload para um servidor, garantindo privacidade total." + }, + "platforms": { + "question": "Funciona no Mac, Windows e Celular?", + "answer": "Sim! Como o BentoPDF roda inteiramente no navegador, funciona em qualquer sistema operacional moderno, incluindo Windows, macOS, Linux, iOS e Android." + }, + "gdprCompliant": { + "question": "O BentoPDF está em conformidade com a GDPR?", + "answer": "Sim. Como o processamento é local e não coletamos seus arquivos, não temos acesso aos seus dados. Isso garante total conformidade e controle por parte do usuário." + }, + "dataStorage": { + "question": "Vocês armazenam ou rastreiam meus arquivos?", + "answer": "Não. Nunca armazenamos, rastreamos ou registramos seus arquivos. Tudo acontece na memória do navegador e desaparece ao fechar a página. Não há logs nem servidores envolvidos." + }, + "different": { + "question": "O que torna o BentoPDF diferente de outras ferramentas?", + "answer": "A maioria das ferramentas faz upload dos arquivos para um servidor. O BentoPDF usa tecnologia web moderna para processar tudo localmente no seu navegador, garantindo mais velocidade e privacidade." + }, + "browserBased": { + "question": "Como o processamento no navegador me mantém seguro?", + "answer": "Ao rodar no seu dispositivo, eliminamos riscos de ataques a servidores ou vazamentos de dados de terceiros. Seus arquivos permanecem seus — sempre." + }, + "analytics": { + "question": "Vocês usam cookies ou rastreamento?", + "answer": "Usamos apenas o Simple Analytics para contar visitas de forma anônima. Sabemos quantos usuários nos visitam, mas nunca quem você é. O sistema respeita totalmente a GDPR." + } + }, + "testimonials": { + "title": "O que nossos", + "users": "Usuários", + "say": "Dizem" + }, + "support": { + "title": "Gostou do Trabalho?", + "description": "O BentoPDF é um projeto pessoal feito para fornecer ferramentas poderosas e privadas para todos. Se for útil para você, considere apoiar o desenvolvimento!", + "buyMeCoffee": "Pague um Café" + }, + "footer": { + "copyright": "© 2025 BentoPDF. Todos os direitos reservados.", + "version": "Versão", + "company": "Empresa", + "aboutUs": "Sobre Nós", + "faqLink": "FAQ", + "contactUs": "Contato", + "legal": "Jurídico", + "termsAndConditions": "Termos e Condições", + "privacyPolicy": "Política de Privacidade", + "followUs": "Siga-nos" + }, + "merge": { + "title": "Mesclar PDFs", + "description": "Combine arquivos inteiros ou selecione páginas específicas para criar um novo documento.", + "fileMode": "Modo Arquivo", + "pageMode": "Modo Página", + "howItWorks": "Como funciona:", + "fileModeInstructions": [ + "Clique e arraste o ícone para alterar a ordem dos arquivos.", + "No campo \"Páginas\", você pode definir intervalos (ex: \"1-3, 5\") para mesclar apenas essas páginas.", + "Deixe o campo em branco para incluir todas as páginas do arquivo." + ], + "pageModeInstructions": [ + "Todas as páginas dos PDFs enviados aparecem abaixo.", + "Arraste as miniaturas para criar a ordem exata que deseja no novo arquivo." + ], + "mergePdfs": "Mesclar PDFs" + }, + "common": { + "page": "Página", + "pages": "Páginas", + "of": "de", + "download": "Baixar", + "cancel": "Cancelar", + "save": "Salvar", + "delete": "Excluir", + "edit": "Editar", + "add": "Adicionar", + "remove": "Remover", + "loading": "Carregando...", + "error": "Erro", + "success": "Sucesso", + "file": "Arquivo", + "files": "Arquivos" + }, + "about": { + "hero": { + "title": "Acreditamos que ferramentas PDF devem ser", + "subtitle": "rápidas, privadas e gratuitas.", + "noCompromises": "Sem concessões." + }, + "mission": { + "title": "Nossa Missão", + "description": "Fornecer o kit de ferramentas PDF mais completo, respeitando sua privacidade e sem cobrar por isso. Ferramentas essenciais devem ser acessíveis a todos, sem barreiras." + }, + "philosophy": { + "label": "Nossa Filosofia", + "title": "Privacidade Primeiro. Sempre.", + "description": "Em uma era onde dados são mercadoria, seguimos outro caminho. Todo o processamento ocorre no seu navegador. Arquivos não tocam nossos servidores e não rastreamos você. Privacidade não é apenas um recurso; é nossa base." + }, + "whyBentopdf": { + "title": "Por que o BentoPDF?", + "speed": { + "title": "Feito para Velocidade", + "description": "Sem esperas de upload. Usando tecnologias como WebAssembly, processamos tudo diretamente no navegador com velocidade inigualável." + }, + "free": { + "title": "Totalmente Grátis", + "description": "Sem períodos de teste, assinaturas ou funções \"premium\" bloqueadas. Acreditamos em ferramentas como um serviço público." + }, + "noAccount": { + "title": "Sem Necessidade de Conta", + "description": "Use qualquer ferramenta imediatamente. Não pedimos e-mail, senha ou qualquer dado pessoal. Seu fluxo de trabalho deve ser anônimo." + }, + "openSource": { + "title": "Espírito Open Source", + "description": "Construído com transparência. Utilizamos bibliotecas incríveis como PDF-lib e PDF.js para democratizar o acesso a ferramentas poderosas." + } + }, + "cta": { + "title": "Pronto para começar?", + "description": "Junte-se a milhares de usuários que confiam no BentoPDF. Sinta a diferença da privacidade e do desempenho.", + "button": "Explorar Ferramentas" + } + }, + "contact": { + "title": "Entre em Contato", + "subtitle": "Adoraríamos ouvir você. Se tiver dúvidas, feedback ou sugestões de recursos, não hesite em nos contatar.", + "email": "Você pode nos contatar diretamente por e-mail em:" + }, + "licensing": { + "title": "Licenciamento de", + "subtitle": "Escolha a licença que melhor atende às suas necessidades." + }, + "multiTool": { + "uploadPdfs": "Enviar PDFs", + "upload": "Enviar", + "addBlankPage": "Adicionar Página em Branco", + "edit": "Editar:", + "undo": "Desfazer", + "redo": "Refazer", + "reset": "Redefinir", + "selection": "Seleção:", + "selectAll": "Selecionar Tudo", + "deselectAll": "Desmarcar Tudo", + "rotate": "Girar:", + "rotateLeft": "Esquerda", + "rotateRight": "Direita", + "transform": "Transformar:", + "duplicate": "Duplicar", + "split": "Dividir", + "clear": "Limpar:", + "delete": "Excluir", + "download": "Baixar:", + "downloadSelected": "Baixar Selecionadas", + "exportPdf": "Exportar PDF", + "uploadPdfFiles": "Selecionar Arquivos PDF", + "dragAndDrop": "Arraste arquivos PDF aqui ou clique para selecionar", + "selectFiles": "Selecionar Arquivos", + "renderingPages": "Renderizando páginas...", + "actions": { + "duplicatePage": "Duplicar esta página", + "deletePage": "Excluir esta página", + "insertPdf": "Inserir PDF após esta página", + "toggleSplit": "Alternar divisão após esta página" + }, + "pleaseWait": "Aguarde", + "pagesRendering": "As páginas ainda estão sendo renderizadas. Por favor, aguarde...", + "noPagesSelected": "Nenhuma Página Selecionada", + "selectOnePage": "Selecione pelo menos uma página para baixar.", + "noPages": "Sem Páginas", + "noPagesToExport": "Não há páginas para exportar.", + "renderingTitle": "Renderizando visualizações das páginas", + "errorRendering": "Falha ao renderizar miniaturas das páginas", + "error": "Erro", + "failedToLoad": "Falha ao carregar" + } +} diff --git a/public/locales/pt/tools.json b/public/locales/pt/tools.json new file mode 100644 index 0000000..078194c --- /dev/null +++ b/public/locales/pt/tools.json @@ -0,0 +1,282 @@ +{ + "categories": { + "popularTools": "Ferramentas Populares", + "editAnnotate": "Editar e Anotar", + "convertToPdf": "Converter para PDF", + "convertFromPdf": "Converter de PDF", + "organizeManage": "Organizar e Gerenciar", + "optimizeRepair": "Otimizar e Reparar", + "securePdf": "Segurança de PDF" + }, + "pdfMultiTool": { + "name": "Multiferramenta PDF", + "subtitle": "Mesclar, dividir, organizar, excluir, girar, adicionar páginas em branco, extrair e duplicar em uma única interface." + }, + "mergePdf": { + "name": "Mesclar PDF", + "subtitle": "Combine vários PDFs em um único arquivo. Preserva os favoritos (bookmarks)." + }, + "splitPdf": { + "name": "Dividir PDF", + "subtitle": "Extraia um intervalo de páginas para um novo PDF." + }, + "compressPdf": { + "name": "Comprimir PDF", + "subtitle": "Reduza o tamanho do arquivo do seu PDF." + }, + "pdfEditor": { + "name": "Editor de PDF", + "subtitle": "Anotar, destacar, redigir, comentar, adicionar formas/imagens, pesquisar e visualizar PDFs." + }, + "jpgToPdf": { + "name": "JPG para PDF", + "subtitle": "Crie um PDF a partir de uma ou mais imagens JPG." + }, + "signPdf": { + "name": "Assinar PDF", + "subtitle": "Desenhe, digite ou faça upload da sua assinatura." + }, + "cropPdf": { + "name": "Cortar PDF", + "subtitle": "Corte as margens de cada página do seu PDF." + }, + "extractPages": { + "name": "Extrair Páginas", + "subtitle": "Salve uma seleção de páginas como novos arquivos." + }, + "duplicateOrganize": { + "name": "Duplicar e Organizar", + "subtitle": "Duplique, reordene e exclua páginas." + }, + "deletePages": { + "name": "Excluir Páginas", + "subtitle": "Remova páginas específicas do seu documento." + }, + "editBookmarks": { + "name": "Editar Favoritos", + "subtitle": "Adicione, edite, importe, exclua e extraia favoritos de PDF." + }, + "tableOfContents": { + "name": "Sumário", + "subtitle": "Gere uma página de sumário a partir dos favoritos do PDF." + }, + "pageNumbers": { + "name": "Números de Página", + "subtitle": "Insira números de página no seu documento." + }, + "addWatermark": { + "name": "Adicionar Marca d'Água", + "subtitle": "Carimbe texto ou uma imagem sobre as páginas do seu PDF." + }, + "headerFooter": { + "name": "Cabeçalho e Rodapé", + "subtitle": "Adicione texto no topo e no final das páginas." + }, + "invertColors": { + "name": "Inverter Cores", + "subtitle": "Crie uma versão em \"modo escuro\" do seu PDF." + }, + "backgroundColor": { + "name": "Cor de Fundo", + "subtitle": "Altere a cor de fundo do seu PDF." + }, + "changeTextColor": { + "name": "Alterar Cor do Texto", + "subtitle": "Altere a cor do texto no seu PDF." + }, + "addStamps": { + "name": "Adicionar Carimbos", + "subtitle": "Adicione carimbos de imagem ao seu PDF usando a barra de ferramentas de anotação.", + "usernameLabel": "Nome do Usuário no Carimbo", + "usernamePlaceholder": "Digite seu nome (para os carimbos)", + "usernameHint": "Este nome aparecerá nos carimbos que você criar." + }, + "removeAnnotations": { + "name": "Remover Anotações", + "subtitle": "Remova comentários, destaques e links." + }, + "pdfFormFiller": { + "name": "Preenchimento de Formulário", + "subtitle": "Preencha formulários diretamente no navegador. Também suporta formulários XFA." + }, + "createPdfForm": { + "name": "Criar Formulário PDF", + "subtitle": "Crie formulários PDF preenchíveis com campos de texto de arrastar e soltar." + }, + "removeBlankPages": { + "name": "Remover Páginas em Branco", + "subtitle": "Detecte e exclua automaticamente páginas em branco." + }, + "imageToPdf": { + "name": "Imagem para PDF", + "subtitle": "Converta JPG, PNG, WebP, BMP, TIFF, SVG, HEIC para PDF." + }, + "pngToPdf": { + "name": "PNG para PDF", + "subtitle": "Crie um PDF a partir de uma ou mais imagens PNG." + }, + "webpToPdf": { + "name": "WebP para PDF", + "subtitle": "Crie um PDF a partir de uma ou mais imagens WebP." + }, + "svgToPdf": { + "name": "SVG para PDF", + "subtitle": "Crie um PDF a partir de uma ou mais imagens SVG." + }, + "bmpToPdf": { + "name": "BMP para PDF", + "subtitle": "Crie um PDF a partir de uma ou mais imagens BMP." + }, + "heicToPdf": { + "name": "HEIC para PDF", + "subtitle": "Crie um PDF a partir de uma ou mais imagens HEIC." + }, + "tiffToPdf": { + "name": "TIFF para PDF", + "subtitle": "Crie um PDF a partir de uma ou mais imagens TIFF." + }, + "textToPdf": { + "name": "Texto para PDF", + "subtitle": "Converta um arquivo de texto simples (.txt) em PDF." + }, + "jsonToPdf": { + "name": "JSON para PDF", + "subtitle": "Converta arquivos JSON para o formato PDF." + }, + "pdfToJpg": { + "name": "PDF para JPG", + "subtitle": "Converta cada página do PDF em uma imagem JPG." + }, + "pdfToPng": { + "name": "PDF para PNG", + "subtitle": "Converta cada página do PDF em uma imagem PNG." + }, + "pdfToWebp": { + "name": "PDF para WebP", + "subtitle": "Converta cada página do PDF em uma imagem WebP." + }, + "pdfToBmp": { + "name": "PDF para BMP", + "subtitle": "Converta cada página do PDF em uma imagem BMP." + }, + "pdfToTiff": { + "name": "PDF para TIFF", + "subtitle": "Converta cada página do PDF em uma imagem TIFF." + }, + "pdfToGreyscale": { + "name": "PDF para Tons de Cinza", + "subtitle": "Converta todas as cores para preto e branco." + }, + "pdfToJson": { + "name": "PDF para JSON", + "subtitle": "Converta arquivos PDF para o formato JSON." + }, + "ocrPdf": { + "name": "OCR PDF", + "subtitle": "Torne um PDF pesquisável e copiável (reconhecimento de texto)." + }, + "alternateMix": { + "name": "Alternar e Misturar Páginas", + "subtitle": "Mescle PDFs alternando as páginas de cada arquivo. Preserva os favoritos." + }, + "addAttachments": { + "name": "Adicionar Anexos", + "subtitle": "Incorpore um ou mais arquivos dentro do seu PDF." + }, + "extractAttachments": { + "name": "Extrair Anexos", + "subtitle": "Extraia todos os arquivos incorporados de PDF(s) como um ZIP." + }, + "editAttachments": { + "name": "Editar Anexos", + "subtitle": "Visualize ou remova anexos do seu PDF." + }, + "dividePages": { + "name": "Dividir Páginas", + "subtitle": "Divida as páginas horizontalmente ou verticalmente." + }, + "addBlankPage": { + "name": "Adicionar Página em Branco", + "subtitle": "Insira uma página vazia em qualquer lugar do seu PDF." + }, + "reversePages": { + "name": "Inverter Páginas", + "subtitle": "Inverta a ordem de todas as páginas do seu documento." + }, + "rotatePdf": { + "name": "Girar PDF", + "subtitle": "Gire as páginas em incrementos de 90 graus." + }, + "nUpPdf": { + "name": "PDF N-Up", + "subtitle": "Organize várias páginas em uma única folha de impressão." + }, + "combineToSinglePage": { + "name": "Combinar em Página Única", + "subtitle": "Costure todas as páginas em um único fluxo contínuo." + }, + "viewMetadata": { + "name": "Ver Metadados", + "subtitle": "Inspecione as propriedades ocultas do seu PDF." + }, + "editMetadata": { + "name": "Editar Metadados", + "subtitle": "Altere o autor, título e outras propriedades." + }, + "pdfsToZip": { + "name": "PDFs para ZIP", + "subtitle": "Empacote vários arquivos PDF em um arquivo compactado ZIP." + }, + "comparePdfs": { + "name": "Comparar PDFs", + "subtitle": "Compare dois PDFs lado a lado." + }, + "posterizePdf": { + "name": "Posterizar PDF", + "subtitle": "Divida uma página grande em várias páginas menores." + }, + "fixPageSize": { + "name": "Ajustar Tamanho da Página", + "subtitle": "Padronize todas as páginas para um tamanho uniforme." + }, + "linearizePdf": { + "name": "Linearizar PDF", + "subtitle": "Otimize o PDF para visualização rápida na web." + }, + "pageDimensions": { + "name": "Dimensões da Página", + "subtitle": "Analise o tamanho, orientação e unidades das páginas." + }, + "removeRestrictions": { + "name": "Remover Restrições", + "subtitle": "Remova proteção por senha e restrições de segurança de arquivos assinados digitalmente." + }, + "repairPdf": { + "name": "Reparar PDF", + "subtitle": "Recupere dados de arquivos PDF corrompidos ou danificados." + }, + "encryptPdf": { + "name": "Criptografar PDF", + "subtitle": "Bloqueie seu PDF adicionando uma senha." + }, + "sanitizePdf": { + "name": "Sanitizar PDF", + "subtitle": "Remova metadados, anotações, scripts e outros dados ocultos." + }, + "decryptPdf": { + "name": "Descriptografar PDF", + "subtitle": "Desbloqueie o PDF removendo a proteção por senha." + }, + "flattenPdf": { + "name": "Achatar PDF (Flatten)", + "subtitle": "Torne os campos de formulário e anotações não editáveis." + }, + "removeMetadata": { + "name": "Remover Metadados", + "subtitle": "Limpe dados ocultos do seu PDF." + }, + "changePermissions": { + "name": "Alterar Permissões", + "subtitle": "Defina ou altere as permissões de usuário em um PDF." + } +} From be59c2b8ea43e2387ae9d6f12092610d85a34611 Mon Sep 17 00:00:00 2001 From: Stephan Paternotte Date: Sat, 20 Dec 2025 11:54:46 +0100 Subject: [PATCH 02/73] Create tools.json Introducing Dutch language --- public/locales/nl/tools.json | 282 +++++++++++++++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 public/locales/nl/tools.json diff --git a/public/locales/nl/tools.json b/public/locales/nl/tools.json new file mode 100644 index 0000000..34df0dd --- /dev/null +++ b/public/locales/nl/tools.json @@ -0,0 +1,282 @@ +{ + "categories": { + "popularTools": "Populaire Tools", + "editAnnotate": "Bewerken & Annoteren", + "convertToPdf": "Converteren naar PDF", + "convertFromPdf": "Converteren van PDF", + "organizeManage": "Organiseren & Beheren", + "optimizeRepair": "Optimaliseren & Repareren", + "securePdf": "PDF beveiligen" + }, + "pdfMultiTool": { + "name": "PDF Multi-tool", + "subtitle": "Samenvoegen, Splitsen, Organiseren, Verwijderen, Roteren, Blanco pagina's toevoegen, Extraheren and Dupliceren in een enkele werkomgeving." + }, + "mergePdf": { + "name": "PDF Samenvoegen", + "subtitle": "Meerdere PDFs combineren tot een enkel bestand. Bladwijzers behouden." + }, + "splitPdf": { + "name": "PDF Splitsen", + "subtitle": "Een reeks pagina's opslaan in een nieuwe PDF." + }, + "compressPdf": { + "name": "PDF Comprimeren", + "subtitle": "De bestandsgrootte van je PDF verkleinen." + }, + "pdfEditor": { + "name": "PDF Editor", + "subtitle": "PDFs annoteren, markeren, redigeren, commentaar toevoegen, vormen/afbeelding toevoegen, doorzoeken and weergeven." + }, + "jpgToPdf": { + "name": "JPG naar PDF", + "subtitle": "Een of meer JPG-afbeeldingen opslaan als PDF." + }, + "signPdf": { + "name": "PDF Ondertekenen", + "subtitle": "Je handtekening tekenen, typen, of invoegen." + }, + "cropPdf": { + "name": "PDF Bijsnijden", + "subtitle": "De marges aanpassen van alle pagina's in je PDF." + }, + "extractPages": { + "name": "Pagina's Extraheren", + "subtitle": "Een selectie van pagina's opslaan als nieuw bestand." + }, + "duplicateOrganize": { + "name": "Dupliceren & Organiseren", + "subtitle": "Pagina's dupliceren, ordenen en verwijderen." + }, + "deletePages": { + "name": "Pagina's Verwijderen", + "subtitle": "Specifieke pagina's uit een dodocument verwijderen." + }, + "editBookmarks": { + "name": "Bladwijzers Bewerken", + "subtitle": "PDF-bladwijzers toevoegen, bewerken, importeren, verwijderen and extraheren." + }, + "tableOfContents": { + "name": "Inhoudsopgave", + "subtitle": "Een inhoudsopgave genereren van PDF-bladwijzers." + }, + "pageNumbers": { + "name": "Paginanummers", + "subtitle": "Paginanummers aan je document toevoegen." + }, + "addWatermark": { + "name": "Watermerk toevoegen", + "subtitle": "Tekst of een afbeelding over de pagina's van je PDF stempelen." + }, + "headerFooter": { + "name": "Koptekst & Voettekst", + "subtitle": "Tekst boven- of onderaan de pagina's toevoegen." + }, + "invertColors": { + "name": "Kleuren Omkeren", + "subtitle": "Maak een \"donkere modus\"-versie van je PDF." + }, + "backgroundColor": { + "name": "Achtergrondkleur", + "subtitle": "Wijzig de achtergrondkleur van je PDF." + }, + "changeTextColor": { + "name": "Tekstkleur", + "subtitle": "Wijzig de tekstkleur van je PDF." + }, + "addStamps": { + "name": "Stempels Toevoegen", + "subtitle": "Voeg afbeeldingsstempels toe aan je PDF met de werkbalk Annotatie.", + "usernameLabel": "Gebruikersnaam stempelen", + "usernamePlaceholder": "Voer je naam in (voor stempels)", + "usernameHint": "Deze naam verschijnt op stempels die jij aanmaakt." + }, + "removeAnnotations": { + "name": "Annotaties Verwijderen", + "subtitle": "Commentaar, markeringen en links verwijderen." + }, + "pdfFormFiller": { + "name": "PDF-formulier Vullen", + "subtitle": "Vul formulieren in vanuit de browser, incl. ondersteuning voor XFA-formulieren." + }, + "createPdfForm": { + "name": "PDF-formulier Aanmaken", + "subtitle": "Maak invulbare PDF-formulieren aan met drag-and-drop tekstvelden." + }, + "removeBlankPages": { + "name": "Blanco pagina's Verwijderen", + "subtitle": "Automatisch blanco pagina's detecteren en verwijderen." + }, + "imageToPdf": { + "name": "Afbeelding naar PDF", + "subtitle": "Converteer JPG, PNG, WebP, BMP, TIFF, SVG, HEIC naar PDF." + }, + "pngToPdf": { + "name": "PNG naar PDF", + "subtitle": "Maak een PDF van een of meer PNG-afbeeldingen." + }, + "webpToPdf": { + "name": "WebP naar PDF", + "subtitle": "Maak een PDF van een of meer WebP-afbeeldingen." + }, + "svgToPdf": { + "name": "SVG naar PDF", + "subtitle": "Maak een PDF van een of meer SVG-afbeeldingen." + }, + "bmpToPdf": { + "name": "BMP naar PDF", + "subtitle": "Maak een PDF van een of meer BMP-afbeeldingen." + }, + "heicToPdf": { + "name": "HEIC naar PDF", + "subtitle": "Maak een PDF van een of meer HEIC-afbeeldingen." + }, + "tiffToPdf": { + "name": "TIFF naar PDF", + "subtitle": "Maak een PDF van een of meer TIFF-afbeeldingen." + }, + "textToPdf": { + "name": "Tekst naar PDF", + "subtitle": "Converteer een bestand met platte tekst naar een PDF." + }, + "jsonToPdf": { + "name": "JSON naar PDF", + "subtitle": "Converteer JSON-bestanden naar PDF-formaat." + }, + "pdfToJpg": { + "name": "PDF naar JPG", + "subtitle": "Converteer elke PDF-pagina naar een JPG-afbeelding." + }, + "pdfToPng": { + "name": "PDF naar PNG", + "subtitle": "Converteer elke PDF-pagina naar een PNG-afbeelding." + }, + "pdfToWebp": { + "name": "PDF naar WebP", + "subtitle": "Converteer elke PDF-pagina naar een WebP-afbeelding." + }, + "pdfToBmp": { + "name": "PDF naar BMP", + "subtitle": "Converteer elke PDF-pagina naar een BMP-afbeelding." + }, + "pdfToTiff": { + "name": "PDF naar TIFF", + "subtitle": "Converteer elke PDF-pagina naar een TIFF-afbeelding." + }, + "pdfToGreyscale": { + "name": "PDF naar Grijswaarden", + "subtitle": "Converteer alle kleuren naar zwart-wit." + }, + "pdfToJson": { + "name": "PDF naar JSON", + "subtitle": "Converteer PDF-bestanden naar JSON-formaat." + }, + "ocrPdf": { + "name": "OCR PDF", + "subtitle": "Maak een PDF doorzoekbaar en kopieerbaar." + }, + "alternateMix": { + "name": "Pagina's Afwisselen & Mixen", + "subtitle": "Voeg PDF-en samen met afwisselende pagina's vanuit elke PDF. Bladwijzers behouden." + }, + "addAttachments": { + "name": "Bijlagen Toevoegen", + "subtitle": "Voeg een of meerbestanden in je PDF." + }, + "extractAttachments": { + "name": "Bijlagen Extraheren", + "subtitle": "Extraheer alle ingevoegde bestanden als ZIP uit PDF(-en)." + }, + "editAttachments": { + "name": "Bijlagen Bewerken", + "subtitle": "Bijlagen in je PDF weergeven of verwijderen." + }, + "dividePages": { + "name": "Paginas Opdelen", + "subtitle": "Pagina's horizontaal of verticaal opdelen." + }, + "addBlankPage": { + "name": "Blanco pagina Toevoegen", + "subtitle": "Een blanco pagina ergens in je PDF invoegen." + }, + "reversePages": { + "name": "Paginavolgorde Omkeren", + "subtitle": "Keer de volgorde om van alle pagina's in je document." + }, + "rotatePdf": { + "name": "PDF Roteren", + "subtitle": "Roteer pagina's in stappen van 90 gradden." + }, + "nUpPdf": { + "name": "N-Up PDF", + "subtitle": "Arrangeer meerdere pagina's op een enkel blad." + }, + "combineToSinglePage": { + "name": "Combineren tot Enkele pagina", + "subtitle": "Plak alle pagina's aan elkaar tot een doorlopende rol." + }, + "viewMetadata": { + "name": "Metadata Weergeven", + "subtitle": "Bekijk de verborgen eigenschappen van je PDF." + }, + "editMetadata": { + "name": "Metadata Bewerken", + "subtitle": "Auteur, titel en andere eigenschappen aanpassen." + }, + "pdfsToZip": { + "name": "PDF naar ZIP", + "subtitle": "Archiveer meerdere PDF-en in een ZIP-bestand." + }, + "comparePdfs": { + "name": "PDF-en Vergelijken", + "subtitle": "Twee PDF-en zij-aan-zij vergelijken." + }, + "posterizePdf": { + "name": "PDF-Poster", + "subtitle": "Deel een grote pagina op in meerdere kleinere pagina's." + }, + "fixPageSize": { + "name": "Paginagrootte Fiksen", + "subtitle": "Alle pagina's aanpassen tot een uniform formaat." + }, + "linearizePdf": { + "name": "PDF Lineariseren", + "subtitle": "Optimaliseer een PDF voor snelle webweergave." + }, + "pageDimensions": { + "name": "Pagina Afmetingen", + "subtitle": "Maak een analyse van paginagrootte, oriëntatie en eenheden." + }, + "removeRestrictions": { + "name": "Beperkingen Verwijderen", + "subtitle": "Beveiligingswachtwoord en -beperkingen verwijderen van digitaal ondertekende PDF-bestanden." + }, + "repairPdf": { + "name": "PDF Repareren", + "subtitle": "Gegevens herstellen van beschadigde PDF-bestanden." + }, + "encryptPdf": { + "name": "PDF Versleutelen", + "subtitle": "Vegrendel je PDF door toevoeging van een toegangswachtwoord." + }, + "sanitizePdf": { + "name": "PDF Opschonen", + "subtitle": "Metadata, annotaties, scripts en meer verwijderen." + }, + "decryptPdf": { + "name": "PDF Ontgrendelen", + "subtitle": "PDF ontgrendelen door het verwijderen van de wachtwoordbeveiliging." + }, + "flattenPdf": { + "name": "PDF Platmaken", + "subtitle": "Maak formuliervelden en annotaties onbewerkbaar." + }, + "removeMetadata": { + "name": "Metadata Verwijderen", + "subtitle": "Verwijder verborgen gegevens uit je PDF." + }, + "changePermissions": { + "name": "Rechten Aanpassen", + "subtitle": "Gebruikersrechten van een PDF instellen of aanpasssen." + } +} From 7f5dafcd2f3be559fa2a738d3e5347361695c0e2 Mon Sep 17 00:00:00 2001 From: Stephan Paternotte Date: Sat, 20 Dec 2025 11:55:41 +0100 Subject: [PATCH 03/73] Create common.json Introducing Dutch language --- public/locales/nl/common.json | 318 ++++++++++++++++++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 public/locales/nl/common.json diff --git a/public/locales/nl/common.json b/public/locales/nl/common.json new file mode 100644 index 0000000..aa7d6cd --- /dev/null +++ b/public/locales/nl/common.json @@ -0,0 +1,318 @@ +{ + "nav": { + "home": "Thuis", + "about": "Over", + "contact": "Contact", + "licensing": "Licentie", + "allTools": "Alle Tools", + "openMainMenu": "Hoofdmenu openen", + "language": "Taal" + }, + "hero": { + "title": "De", + "pdfToolkit": "PDF Toolkit", + "builtForPrivacy": "gemaakt voor privacy", + "noSignups": "Geen aanmelding", + "unlimitedUse": "Onbegrenst gebruik", + "worksOffline": "Werkt Offline", + "startUsing": "Direct aan de slag" + }, + "usedBy": { + "title": "Gebruikt door bedrijven en personen werkzaam bij" + }, + "features": { + "title": "Waarom", + "bentoPdf": "BentoPDF?", + "noSignup": { + "title": "Geen aanmelding", + "description": "Direct aan de slag, zonder account of e-mails." + }, + "noUploads": { + "title": "Geen uploads", + "description": "100% client-side, je bestanden blijven op jouw apparaat." + }, + "foreverFree": { + "title": "Voor altijd gratis", + "description": "Alle tools, geen proef, geen betaalmuur." + }, + "noLimits": { + "title": "Geen beperkingen", + "description": "Gebruik het zoveel je wilt, geen verborgen limiet." + }, + "batchProcessing": { + "title": "Reeksen verwerken", + "description": "Verwerk in een keer een onbeperkt aantal PDF-en." + }, + "lightningFast": { + "title": "Bliksemsnel", + "description": "Verwerk PDF-en direct, zonder wachten of vertraging." + } + }, + "tools": { + "title": "Aan de slag met", + "toolsLabel": "Tools", + "subtitle": "Klik een tool om de bestandslader te openen", + "searchPlaceholder": "Zoek een tool (bijv., 'splitsen', 'organiseren'...)", + "backToTools": "Terug naar Tools" + }, + "upload": { + "clickToSelect": "Klik om een bestand te selecteren", + "orDragAndDrop": "of sleep er een hierheen", + "pdfOrImages": "PDF-en of Afbeeldingen", + "filesNeverLeave": "Je bestanden blijven op jouw apparaat.", + "addMore": "Meer bestanden toevoegen", + "clearAll": "Alles wissen" + }, + "loader": { + "processing": "Verwerken..." + }, + "alert": { + "title": "Let op", + "ok": "OK" + }, + "preview": { + "title": "Document Weergeven", + "downloadAsPdf": "Downloaden als PDF", + "close": "Sluiten" + }, + "settings": { + "title": "Instellingen", + "shortcuts": "Sneltoetsen", + "preferences": "Voorkeuren", + "displayPreferences": "Voorkeuren weergeven", + "searchShortcuts": "Sneltoetsen zoeken...", + "shortcutsInfo": "Houd toetsen ingedrukt om deze in te stellen als sneltoets. Aanpassingen worden automatisch opgeslagen.", + "shortcutsWarning": "⚠️ Voorkom algemene sneltoetsen voor browsers (Cmd/Ctrl+W, Cmd/Ctrl+T, Cmd/Ctrl+N etc.) aangezien deze niet betrouwbaar kunnen functioneren.", + "import": "Importeren", + "export": "Exporteren", + "resetToDefaults": "Terugzetten naar standaard", + "fullWidthMode": "Volledige breedte", + "fullWidthDescription": "Gebruik de volledige breedte van het scherm in plaats van een gecentreerde kolom", + "settingsAutoSaved": "Instellingen worden automatisch opgeslagen", + "clickToSet": "Klik om in te stellen", + "pressKeys": "Druk toetsen...", + "warnings": { + "alreadyInUse": "Sneltoets al in gebruik", + "assignedTo": "is al toegewezen aan:", + "chooseDifferent": "Kies een andere sneltoets.", + "reserved": "Waarschuwing voor gereserveerde sneltoets", + "commonlyUsed": "wordt algemeen gebruikt voor:", + "unreliable": "Deze sneltoets werkt mogelijk niet goed of is in conflict met gedrag van browser of systeem.", + "useAnyway": "Wil je het toch gebruiken?", + "resetTitle": "Sneltoetsen terugzetten", + "resetMessage": "Weet je zeker dat je alle sneltoetsen wilt terugzetten naar standaard?

Deze actie kan niet ongedaan worden gemaakt.", + "importSuccessTitle": "Import succesvoll", + "importSuccessMessage": "Sneltoetsen zijn met succes geïmporteerd!", + "importFailTitle": "Import mislukt", + "importFailMessage": "Het importeren van sneltoetsen is mislukt. Ongeldig bestandsformaat." + } + }, + "warning": { + "title": "Waarschuwing", + "cancel": "Annuleren", + "proceed": "Verder" + }, + "compliance": { + "title": "Jouw gegevens blijven op jouw appraat", + "weKeep": "Wij houden", + "yourInfoSafe": "jouw informatie veilig", + "byFollowingStandards": "volgens de volgende algemene veiligheidsstandaarden.", + "processingLocal": "Alle verwerking vindt lokaal plaats op jouw apparaat.", + "gdpr": { + "title": "AVG-naleving", + "description": "Beschermt persoonlijke gegevens en privacy van individuën binnen de Europese Unie." + }, + "ccpa": { + "title": "CCPA-naleving", + "description": "Geeft inwoners van Californië rechten over hoe hun persoonlijke gegevens worden verzameld, gebruikt en gedeeld." + }, + "hipaa": { + "title": "HIPAA-naleving", + "description": "Stelt waarborgen in voor het omgaan met gevoelige gezondheidsinformatie in het gezondheidszorgsysteem van de Verenigde Staten." + } + }, + "faq": { + "title": "Vaak gestelde vragen", + "questions": "Vragen", + "isFree": { + "question": "Is BentoPDF echt gratis?", + "answer": "Ja, absoluut. Alle tools op BentoPDF zijn 100% gratis te gebruiken, zonder bestandslimieten, zonder aanmeldingen en zonder watermerken. Wij vinden dat iedereen toegang verdient tot eenvoudige, krachtige PDF-tools zonder betaalmuur." + }, + "areFilesSecure": { + "question": "Zijn mijn bestanden veilig? Waar worden ze verwerkt?", + "answer": "Je bestanden zijn zo veilig mogelijk omdat ze je computer nooit verlaten. Alle verwerking gebeurt direct in je webbrowser (client-side). Je bestanden worden nooit naar een server ge-upload, zodat je volledige privacy en controle over je documenten behoudt." + }, + "platforms": { + "question": "Werkt het op Mac, Windows en mobiel?", + "answer": "Ja! Omdat BentoPDF volledig in je browser werkt, werkt het op elk besturingssysteem met een moderne webbrowser, inclusief Windows, macOS, Linux, iOS en Android." + }, + "gdprCompliant": { + "question": "Is BentoPDF AVG-conform?", + "answer": "Ja. BentoPDF voldoet volledig aan de AVG. Omdat alle bestandsverwerking lokaal in je browser gebeurt en we nooit je bestanden naar een server sturen of verzamelen, hebben wij geen toegang tot je gegevens. Zo houd jij altijd de controle over je documenten." + }, + "dataStorage": { + "question": "Slaan jullie mijn bestanden op of volgen jullie die?", + "answer": "Nee. We slaan je bestanden nooit op, volgen ze niet en houden er geen logboek van bij. Alles wat je op BentoPDF doet, gebeurt in het geheugen van je browser en verdwijnt zodra je de pagina sluit. Er is geen upload, geen geschiedenislogboek en geen servers bij betrokken." + }, + "different": { + "question": "Wat maakt BentoPDF anders dan andere PDF-tools?", + "answer": "De meeste PDF-tools verlangen dat je je bestanden voor verwerking naar een server uploadt. BentoPDF doet dat nooit. Wij gebruiken veilige, moderne webtechnologie om je bestanden direct in je browser te verwerken. Dit betekent snellere prestaties, betere privacy en totale gemoedsrust." + }, + "browserBased": { + "question": "Hoe zorgt browsergebaseerde verwerking ervoor dat ik veilig blijf?", + "answer": "Omdat BentoPDF helemaal in je browser draait, blijven je bestanden altijd op je apparaat. Daardoor hoef je je geen zorgen te maken over hacks, datalekken of ongeoorloofde toegang. Je bestanden zijn altijd van jou." + }, + "analytics": { + "question": "Gebruikt BentoPDF cookies of analytics om mij te volgen?", + "answer": "We geven om je privacy. BentoPDF houdt geen persoonlijke gegevens bij. We gebruiken Simple Analytics alleen om anonieme bezoekersaantallen te zien. Dit betekent dat we kunnen weten hoeveel mensen onze site bezoeken, maar we weten nooit wie jij bent. Simple Analytics is volledig AVG-conform en respecteert je privacy." + } + }, + "testimonials": { + "title": "Wat Onze", + "users": "Gebruikers", + "say": "Zeggen" + }, + "support": { + "title": "Vind je mijn werk leuk?", + "description": "BentoPDF is een project van passie, gemaakt om iedereen een gratis, privé en krachtig PDF-gereedschap te bieden. Als je het handig vindt, overweeg dan om de ontwikkeling te steunen. Elke koffie helpt!", + "buyMeCoffee": "Koop een kopje koffie voor me" + }, + "footer": { + "copyright": "© 2025 BentoPDF. Alle rechten voorbehouden.", + "version": "Versie", + "company": "Bedrijf", + "aboutUs": "Over ons", + "faqLink": "FAQ", + "contactUs": "Contact", + "legal": "Juridisch", + "termsAndConditions": "Algemene voorwaarden", + "privacyPolicy": "Privacybeleid", + "followUs": "Volgen" + }, + "merge": { + "title": "PDF-en Samenvoegen", + "description": "Combineer hele bestanden, of selecteer specifieke pagina's om te samen te voegen tot een nieuw document.", + "fileMode": "Bestandsmodus", + "pageMode": "Paginamodus", + "howItWorks": "Hoe het werkt:", + "fileModeInstructions": [ + "Klik en sleep het pictogram om de volgorde van de bestanden te wijzigen.", + "In het vak \"Pagina's\" voor elk bestand kan je reeksen opgeven (bijv. \"1-3, 5\") om alleen die pagina's samen te voegen.", + "Laat het vak \"Pagina's\" leeg om alle pagina's in het bestand op te nemen." + ], + "pageModeInstructions": [ + "Alle pagina's van je PDF-en worden hieronder weergegeven.", + "Sleep afzonderlijke pagina-miniaturen in de gewenste volgorde voor je nieuwe bestand." + ], + "mergePdfs": "PDF-en Samenvoegen" + }, + "common": { + "page": "Pagina", + "pages": "Pagina's", + "of": "van", + "download": "Download", + "cancel": "Annuleren", + "save": "Opslaan", + "delete": "Verwijderen", + "edit": "Bewerken", + "add": "Toevoegen", + "remove": "Verwijderen", + "loading": "Laden...", + "error": "Fout", + "success": "Success", + "file": "Bestand", + "files": "Bestanden" + }, + "about": { + "hero": { + "title": "Wij vinden dat PDF-tools", + "subtitle": "snel, privé, en gratis moeten zijn.", + "noCompromises": "Geen compromissen." + }, + "mission": { + "title": "Onze Missie", + "description": "Ons doel is om de meest complete PDF-toolbox te bieden die je privacy respecteert en nooit om betaling vraagt. Wij geloven dat essentiële documententools voor iedereen, overal en zonder obstakels toegankelijk moeten zijn." + }, + "philosophy": { + "label": "Onze kernfilosofie", + "title": "Privacy First. Always.", + "description": "In een tijdperk waarin data een handelswaar is, doen wij het net even anders. Alle verwerking voor BentoPDF-tools gebeurt lokaal in je browser. Dat betekent dat je bestanden nooit oop onze servers terechtkomen, dat wij je documenten nooit zien en niets volgen van wat je doet. Je documenten blijven volledig privé, punt. Het is niet zomaar een functie; het is onze basis." + }, + "whyBentopdf": { + "title": "Waarom", + "speed": { + "title": "Gemaakt voor snelheid", + "description": "Geen wachttijd voor uploads of downloads naar een server. Door bestanden direct in je browser te verwerken met moderne webtechnologieën zoals WebAssembly, bieden we ongeëvenaarde snelheid voor al onze tools." + }, + "free": { + "title": "Volledig gratis", + "description": "Geen proefversies, geen abonnementen, geen verborgen kosten en geen \"premium\" functies die vworden achtergehouden. Wij vinden dat krachtige PDF-tools een openbare dienst moeten zijn, geen winstmachine." + }, + "noAccount": { + "title": "Geen account vereist", + "description": "Begin meteen met het gebruiken van een tool. Je e-mail, wachtwoord of persoonlijke gegevens hebben we niet nodig. Je workflow kan soepel en anoniem zijn." + }, + "openSource": { + "title": "De geest van Open Source", + "description": "Gebouwd met transparantie in gedachten. We maken gebruik van geweldige open-source bibliotheken zoals PDF-lib en PDF.js en geloven in de communitygedreven inspanning om krachtige tools voor iedereen toegankelijk te maken." + } + }, + "cta": { + "title": "Klaar om te beginnen?", + "description": "Sluit je aan bij duizenden gebruikers die BentoPDF vertrouwen voor hun dagelijkse documentbehoeften. Ervaar zelf het verschil dat privacy en prestaties kunnen maken.", + "button": "Ontdek alle tools" + } + }, + "contact": { + "title": "Neem contact op", + "subtitle": "We horen graag van je. Of je nu een vraag, feedback of een verzoek voor een functie hebt, aarzel dan niet om contact met ons op te nemen.", + "email": "Je kunt ons rechtstreeks bereiken via e-mail op:" + }, + "licensing": { + "title": "Licentie voor", + "subtitle": "Kies de licentie die bij je past." + }, + "multiTool": { + "uploadPdfs": "PDF-en laden", + "upload": "Laden", + "addBlankPage": "Blanco pagina toevoegen", + "edit": "Bewerken:", + "undo": "Ongedaan maken", + "redo": "Opnieuw", + "reset": "Terugzetten", + "selection": "Selectie:", + "selectAll": "Alles selecteren", + "deselectAll": "Selectie opheffen", + "rotate": "Roteren:", + "rotateLeft": "Linksom", + "rotateRight": "Rechtsom", + "transform": "Transformeren:", + "duplicate": "Dupliceren", + "split": "Splitsen", + "clear": "Wissen:", + "delete": "Verwijderen", + "download": "Laden:", + "downloadSelected": "Selectie laden", + "exportPdf": "PDF Exporteren", + "uploadPdfFiles": "Selecteer PDF-bestanden", + "dragAndDrop": "Klik om een bestand te selecteren, of sleep er een hierheen", + "selectFiles": "Bestanden selecteren", + "renderingPages": "Pagina's vewerken...", + "actions": { + "duplicatePage": "Deze pagina dupliceren", + "deletePage": "Deze pagina verwijderen", + "insertPdf": "PDF achter deze pagina invoegen", + "toggleSplit": "Splitsing maken na deze pagina" + }, + "pleaseWait": "Even geduld", + "pagesRendering": "Pagina's worden nog verwerkt. Even geduld...", + "noPagesSelected": "Geen pagina's geselecteerd", + "selectOnePage": "Kies tenminste een pagina om te laden.", + "noPages": "Geen pagina's", + "noPagesToExport": "Er zijn geen pagina's om te exporteren.", + "renderingTitle": "Paginavoorbeeld verwerken", + "errorRendering": "Generatie van pagina-miniaturen is mislukt", + "error": "Fout", + "failedToLoad": "Laden is mislukt" + } +} From 9e99cf6b775024c665a1e9643d4c5bb90ccb6624 Mon Sep 17 00:00:00 2001 From: Stephan Paternotte Date: Sat, 20 Dec 2025 12:02:49 +0100 Subject: [PATCH 04/73] Update i18n.ts Added Dutch (nl) to various lists Alphabetical order --- src/js/i18n/i18n.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/js/i18n/i18n.ts b/src/js/i18n/i18n.ts index f29f7d6..b5eb5a0 100644 --- a/src/js/i18n/i18n.ts +++ b/src/js/i18n/i18n.ts @@ -3,19 +3,20 @@ import LanguageDetector from 'i18next-browser-languagedetector'; import HttpBackend from 'i18next-http-backend'; // Supported languages -export const supportedLanguages = ['en', 'de', 'zh', 'vi'] as const; +export const supportedLanguages = ['en', 'de', 'nl', 'vi', 'zh'] as const; export type SupportedLanguage = (typeof supportedLanguages)[number]; export const languageNames: Record = { en: 'English', de: 'Deutsch', - zh: '中文', + nl: 'Nederlands', vi: 'Tiếng Việt', + zh: '中文', }; export const getLanguageFromUrl = (): SupportedLanguage => { const path = window.location.pathname; - const langMatch = path.match(/^\/(en|de|zh|vi)(?:\/|$)/); + const langMatch = path.match(/^\/(en|de|nl|vi|zh)(?:\/|$)/); if (langMatch && supportedLanguages.includes(langMatch[1] as SupportedLanguage)) { return langMatch[1] as SupportedLanguage; } @@ -72,8 +73,8 @@ export const changeLanguage = (lang: SupportedLanguage): void => { let newPath: string; if (currentPath.match(/^\/(en|de|zh|vi)\//)) { - newPath = currentPath.replace(/^\/(en|de|zh|vi)\//, `/${lang}/`); - } else if (currentPath.match(/^\/(en|de|zh|vi)$/)) { + newPath = currentPath.replace(/^\/(en|de|nl|vi|zh)\//, `/${lang}/`); + } else if (currentPath.match(/^\/(en|de|nl|vi|zh)$/)) { newPath = `/${lang}`; } else { newPath = `/${lang}${currentPath}`; From c4a67b35c79d1aae620615365231a27add8b99db Mon Sep 17 00:00:00 2001 From: Stephan Paternotte Date: Sun, 28 Dec 2025 13:46:22 +0100 Subject: [PATCH 05/73] Update common.json feat: Add VitePress docs, EPUB to PDF tool, Phosphor icons, and licensing updates --- public/locales/nl/common.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/public/locales/nl/common.json b/public/locales/nl/common.json index aa7d6cd..6aa43aa 100644 --- a/public/locales/nl/common.json +++ b/public/locales/nl/common.json @@ -8,6 +8,10 @@ "openMainMenu": "Hoofdmenu openen", "language": "Taal" }, + "donation": { + "message": "Vind je BentoPDF geweldig? Help ons het gratis en open source te houden!", + "button": "Doneren" + }, "hero": { "title": "De", "pdfToolkit": "PDF Toolkit", @@ -54,6 +58,7 @@ "subtitle": "Klik een tool om de bestandslader te openen", "searchPlaceholder": "Zoek een tool (bijv., 'splitsen', 'organiseren'...)", "backToTools": "Terug naar Tools" + "firstLoadNotice": "De eerste keer duurt het even om onze conversiemachine te laden. Daarna gaat alles meteen." }, "upload": { "clickToSelect": "Klik om een bestand te selecteren", From ac0094368b0a926196d44c51dd69ca74a4027571 Mon Sep 17 00:00:00 2001 From: Stephan Paternotte Date: Sun, 28 Dec 2025 13:47:08 +0100 Subject: [PATCH 06/73] Update tools.json feat: Add VitePress docs, EPUB to PDF tool, Phosphor icons, and licensing updates --- public/locales/nl/tools.json | 222 ++++++++++++++++++++++++++++++++++- 1 file changed, 216 insertions(+), 6 deletions(-) diff --git a/public/locales/nl/tools.json b/public/locales/nl/tools.json index 34df0dd..e2a5be9 100644 --- a/public/locales/nl/tools.json +++ b/public/locales/nl/tools.json @@ -23,6 +23,28 @@ "compressPdf": { "name": "PDF Comprimeren", "subtitle": "De bestandsgrootte van je PDF verkleinen." + "algorithmLabel": "Compressie-algoritme", + "condense": "Condense (Aanbevolen)", + "photon": "Photon (Voor PDF's met veel foto's)", + "condenseInfo": "Condense gebruikt geavanceerde compressie: verwijdert overbodige onderdelen, optimaliseert afbeeldingen en kiest alleen de benodigde letters uit fonts. Ideaal voor de meeste PDF's.", + "photonInfo": "Photon zet pagina's om in afbeeldingen. Handig voor PDF's met veel foto's of gescande documenten.", + "photonWarning": "Let op: De tekst kan dan niet meer geselecteerd worden en links werken niet meer.", + "levelLabel": "Compressieniveau", + "light": "Licht (Kwaliteit behouden)", + "balanced": "Gebalanceerd (Aanbevolen)", + "aggressive": "Agressief (Kleinere bestanden)", + "extreme": "Extreem (Maximale compressie)", + "grayscale": "Converteren naar grijswaarden", + "grayscaleHint": "Vermindert de bestandsgrootte door kleurinformatie te verwijderen", + "customSettings": "Aangepaste instellingen", + "customSettingsHint": "Compressie-instellingen verfijnen:", + "outputQuality": "Uitvoerkwaliteit", + "resizeImagesTo": "Formaat van afbeeldingen aanpassen naar", + "onlyProcessAbove": "Alleen verwerken boven", + "removeMetadata": "Metagegevens wissen", + "subsetFonts": "Subset-lettertypen (ongebruikte tekens verwijderen)", + "removeThumbnails": "Ingesloten miniaturen verwijderen", + "compressButton": "PDF Comprimeren" }, "pdfEditor": { "name": "PDF Editor", @@ -109,7 +131,7 @@ }, "imageToPdf": { "name": "Afbeelding naar PDF", - "subtitle": "Converteer JPG, PNG, WebP, BMP, TIFF, SVG, HEIC naar PDF." + "subtitle": "Converteer JPG, PNG, BMP, GIF, TIFF, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2, PSD, SVG, HEIC, WebP naar PDF." }, "pngToPdf": { "name": "PNG naar PDF", @@ -177,7 +199,7 @@ }, "alternateMix": { "name": "Pagina's Afwisselen & Mixen", - "subtitle": "Voeg PDF-en samen met afwisselende pagina's vanuit elke PDF. Bladwijzers behouden." + "subtitle": "Voeg PDF's samen met afwisselende pagina's vanuit elke PDF. Bladwijzers behouden." }, "addAttachments": { "name": "Bijlagen Toevoegen", @@ -207,8 +229,12 @@ "name": "PDF Roteren", "subtitle": "Roteer pagina's in stappen van 90 gradden." }, + "rotateCustom": { + "name": "Roteren met aangepaste hoek", + "subtitle": "Roteer pagina's met elke gewenste hoek." + }, "nUpPdf": { - "name": "N-Up PDF", + "name": "N+ PDF", "subtitle": "Arrangeer meerdere pagina's op een enkel blad." }, "combineToSinglePage": { @@ -225,11 +251,11 @@ }, "pdfsToZip": { "name": "PDF naar ZIP", - "subtitle": "Archiveer meerdere PDF-en in een ZIP-bestand." + "subtitle": "Archiveer meerdere PDF's in een ZIP-bestand." }, "comparePdfs": { - "name": "PDF-en Vergelijken", - "subtitle": "Twee PDF-en zij-aan-zij vergelijken." + "name": "PDF's Vergelijken", + "subtitle": "Twee PDF's zij-aan-zij vergelijken." }, "posterizePdf": { "name": "PDF-Poster", @@ -278,5 +304,189 @@ "changePermissions": { "name": "Rechten Aanpassen", "subtitle": "Gebruikersrechten van een PDF instellen of aanpasssen." + }, + "odtToPdf": { + "name": "ODT naar PDF", + "subtitle": "Converteer OpenDocument-tekstbestanden naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "ODT-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "csvToPdf": { + "name": "CSV naar PDF", + "subtitle": "Converteer CSV-spreadsheetbestanden naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "CSV-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "rtfToPdf": { + "name": "RTF naar PDF", + "subtitle": "Converteer Rich Text Format-documenten naar PDF. Ondersteunt meerdere bestanden.", + "acceptedFormats": "RTF-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "wordToPdf": { + "name": "Word naar PDF", + "subtitle": "Converteer Word-documenten (DOCX, DOC, ODT, RTF) naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "DOCX-, DOC-, ODT-, RTF-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "excelToPdf": { + "name": "Excel naar PDF", + "subtitle": "Converteer Excel-spreadsheets (XLSX, XLS, ODS, CSV) naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "XLSX-, XLS-, ODS-, CSV-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "powerpointToPdf": { + "name": "PowerPoint naar PDF", + "subtitle": "Converteer PowerPoint-presentaties (PPTX, PPT, ODP) naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "PPTX-, PPT-, ODP-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "markdownToPdf": { + "name": "Markdown naar PDF", + "subtitle": "Schrijf of plak Markdown en zet het om in een opgemaakte PDF.", + "paneMarkdown": "Markdown", + "panePreview": "Voorbeeld", + "btnUpload": "Laden", + "btnSyncScroll": "Synchroon bladeren", + "btnSettings": "Instellingen", + "btnExportPdf": "PDF exporteren", + "settingsTitle": "Markdown-instellingen", + "settingsPreset": "Voorinstelling", + "presetDefault": "Standaard (als GFM)", + "presetCommonmark": "CommonMark (strict)", + "presetZero": "Minimaal (geen functies)", + "settingsOptions": "Markdown-opties", + "optAllowHtml": "HTML-tags toestaan", + "optBreaks": "Newlines omzetten naar
", + "optLinkify": "URL's autom. omzetten naar links", + "optTypographer": "Typograaf (slimme aanhalingstekens, etc.)" + }, + "pdfBooklet": { + "name": "PDF Boekje", + "subtitle": "Herschik de pagina's voor dubbelzijdig boekjeprinten. Vouw en niet ze om een boekje te maken.", + "howItWorks": "Het werkt zo:", + "step1": "Laad een PDF-bestand.", + "step2": "Pagina's worden in brochurevolgorde gerangschikt.", + "step3": "Dubbelzijdig afdrukken, omdraaien langs de korte kant, vouwen en nieten.", + "paperSize": "Paperformaat", + "orientation": "Ori�ntatie", + "portrait": "Staand", + "landscape": "Liggend", + "pagesPerSheet": "Pagina's per vel", + "createBooklet": "Boekje aanmaken", + "processing": "Verwerken...", + "pageCount": "Het aantal pagina's wordt indien nodig op een meervoud van 4 afgerond." + }, + "xpsToPdf": { + "name": "XPS naar PDF", + "subtitle": "Converteer XPS/OXPS-documenten naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "XPS-, OXPS-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "mobiToPdf": { + "name": "MOBI naar PDF", + "subtitle": "Converteer MOBI e-books naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "MOBI-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "epubToPdf": { + "name": "EPUB naar PDF", + "subtitle": "Zet EPUB-e-books om naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "EPUB-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "fb2ToPdf": { + "name": "FB2 naar PDF", + "subtitle": "Converteer FictionBook (FB2) e-boeken naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "FB2-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "cbzToPdf": { + "name": "CBZ naar PDF", + "subtitle": "Converteer stripboekenarchieven (CBZ/CBR) naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "CBZ-, CBR-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "wpdToPdf": { + "name": "WPD naar PDF", + "subtitle": "Converteer WordPerfect-documenten (WPD) naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "WPD-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "wpsToPdf": { + "name": "WPS naar PDF", + "subtitle": "Converteer WPS Office-documenten naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "WPS-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "xmlToPdf": { + "name": "XML naar PDF", + "subtitle": "Converteer XML-documenten naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "XML-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "pagesToPdf": { + "name": "Pages naar PDF", + "subtitle": "Zet Apple Pages-documenten om naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "Pages-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "odgToPdf": { + "name": "ODG naar PDF", + "subtitle": "Converteer OpenDocument Graphics (ODG)-bestanden naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "ODG-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "odsToPdf": { + "name": "ODS naar PDF", + "subtitle": "Converteer OpenDocument Spreadsheet (ODS)-bestanden naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "ODS-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "odpToPdf": { + "name": "ODP naar PDF", + "subtitle": "Converteer OpenDocument-presentatie (ODP) bestanden naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "ODP-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "pubToPdf": { + "name": "PUB naar PDF", + "subtitle": "Converteer Microsoft Publisher (PUB) bestanden naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "PUB-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "vsdToPdf": { + "name": "VSD naar PDF", + "subtitle": "Converteer Microsoft Visio (VSD, VSDX) bestanden naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "VSD-, VSDX-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "psdToPdf": { + "name": "PSD naar PDF", + "subtitle": "Converteer Adobe Photoshop (PSD) bestanden naar PDF-formaat. Ondersteunt meerdere bestanden.", + "acceptedFormats": "PSD-bestanden", + "convertButton": "Omzetten naar PDF" + }, + "pdfToSvg": { + "name": "PDF naar SVG", + "subtitle": "Converteer elke pagina van een PDF-bestand naar een schaalbare vectorafbeelding (SVG) voor perfecte kwaliteit op elke grootte." + }, + "extractTables": { + "name": "PDF-tabellen extraheren", + "subtitle": "Haal tabellen uit PDF-bestanden en exporteer ze als CSV, JSON of Markdown." + }, + "pdfToCsv": { + "name": "PDF naar CSV", + "subtitle": "Haal tabellen uit een PDF en zet ze om naar CSV-formaat." + }, + "pdfToExcel": { + "name": "PDF naar Excel", + "subtitle": "Haal tabellen uit PDF en converteer ze naar Excel (XLSX) formaat." + }, + "pdfToText": { + "name": "PDF naar Text", + "subtitle": "Haal tekst uit PDF-bestanden en sla op als gewone tekst (.txt). Ondersteunt meerdere bestanden.", + "note": "Dit hulpmiddel werkt ALLEEN met digitaal gemaakte PDF's. Gebruik voor gescande documenten of op afbeeldingen gebaseerde PDF's in plaats hiervan de OCR PDF-tool.", + "convertButton": "Tekst extraheren" } } From 704045b02441eeb4cd0b5b46aea5fb15cd47d3e9 Mon Sep 17 00:00:00 2001 From: Stephan Paternotte Date: Sun, 28 Dec 2025 14:05:01 +0100 Subject: [PATCH 07/73] Update common.json --- public/locales/nl/common.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/public/locales/nl/common.json b/public/locales/nl/common.json index 6aa43aa..edbe341 100644 --- a/public/locales/nl/common.json +++ b/public/locales/nl/common.json @@ -45,11 +45,11 @@ }, "batchProcessing": { "title": "Reeksen verwerken", - "description": "Verwerk in een keer een onbeperkt aantal PDF-en." + "description": "Verwerk in een keer een onbeperkt aantal PDF's." }, "lightningFast": { "title": "Bliksemsnel", - "description": "Verwerk PDF-en direct, zonder wachten of vertraging." + "description": "Verwerk PDF's direct, zonder wachten of vertraging." } }, "tools": { @@ -63,7 +63,7 @@ "upload": { "clickToSelect": "Klik om een bestand te selecteren", "orDragAndDrop": "of sleep er een hierheen", - "pdfOrImages": "PDF-en of Afbeeldingen", + "pdfOrImages": "PDF's of Afbeeldingen", "filesNeverLeave": "Je bestanden blijven op jouw apparaat.", "addMore": "Meer bestanden toevoegen", "clearAll": "Alles wissen" @@ -195,7 +195,7 @@ "followUs": "Volgen" }, "merge": { - "title": "PDF-en Samenvoegen", + "title": "PDF's Samenvoegen", "description": "Combineer hele bestanden, of selecteer specifieke pagina's om te samen te voegen tot een nieuw document.", "fileMode": "Bestandsmodus", "pageMode": "Paginamodus", @@ -206,10 +206,10 @@ "Laat het vak \"Pagina's\" leeg om alle pagina's in het bestand op te nemen." ], "pageModeInstructions": [ - "Alle pagina's van je PDF-en worden hieronder weergegeven.", + "Alle pagina's van je PDF's worden hieronder weergegeven.", "Sleep afzonderlijke pagina-miniaturen in de gewenste volgorde voor je nieuwe bestand." ], - "mergePdfs": "PDF-en Samenvoegen" + "mergePdfs": "PDF's Samenvoegen" }, "common": { "page": "Pagina", @@ -278,7 +278,7 @@ "subtitle": "Kies de licentie die bij je past." }, "multiTool": { - "uploadPdfs": "PDF-en laden", + "uploadPdfs": "PDF's laden", "upload": "Laden", "addBlankPage": "Blanco pagina toevoegen", "edit": "Bewerken:", @@ -299,7 +299,7 @@ "download": "Laden:", "downloadSelected": "Selectie laden", "exportPdf": "PDF Exporteren", - "uploadPdfFiles": "Selecteer PDF-bestanden", + "uploadPdfFiles": "PDF-bestanden selecteren", "dragAndDrop": "Klik om een bestand te selecteren, of sleep er een hierheen", "selectFiles": "Bestanden selecteren", "renderingPages": "Pagina's vewerken...", From 14afebb5f0657e96807fbf33c88d29fd94387335 Mon Sep 17 00:00:00 2001 From: Raul Gonzalez Date: Fri, 2 Jan 2026 07:00:27 -0600 Subject: [PATCH 08/73] Add spanish translation and update documentation with missing steps --- TRANSLATION.md | 96 ++++++- public/locales/es/common.json | 323 ++++++++++++++++++++++ public/locales/es/tools.json | 492 ++++++++++++++++++++++++++++++++++ src/js/i18n/i18n.ts | 5 +- vite.config.ts | 2 +- 5 files changed, 901 insertions(+), 17 deletions(-) create mode 100644 public/locales/es/common.json create mode 100644 public/locales/es/tools.json diff --git a/TRANSLATION.md b/TRANSLATION.md index 00354a5..04fb60c 100644 --- a/TRANSLATION.md +++ b/TRANSLATION.md @@ -34,7 +34,7 @@ The app automatically detects the language from the URL path: **To improve existing translations:** -1. Navigate to `public/locales/{language}/common.json` +1. Navigate to `public/locales/{language}/common.json` and `public/locales/{language}/tools.json` 2. Find the key you want to update 3. Change the translation value 4. Save and test @@ -42,10 +42,13 @@ The app automatically detects the language from the URL path: **To add a new language (e.g., Spanish):** 1. Copy `public/locales/en/common.json` to `public/locales/es/common.json` -2. Translate all values in `es/common.json` -3. Add Spanish to `supportedLanguages` in `src/js/i18n/i18n.ts` -4. Add Spanish name to `languageNames` in `src/js/i18n/i18n.ts` -5. Test thoroughly +2. Copy `public/locales/en/tools.json` to `public/locales/es/tools.json` +3. Translate all values in both `es/common.json` and `es/tools.json` +4. Add Spanish to `supportedLanguages` in `src/js/i18n/i18n.ts` +5. Add Spanish name to `languageNames` in `src/js/i18n/i18n.ts` +6. Add Spanish language code to the routing regex in `vite.config.ts` +7. Restart the dev server +8. Test thoroughly --- @@ -53,17 +56,18 @@ The app automatically detects the language from the URL path: Let's add **French** as an example: -### Step 1: Create Translation File +### Step 1: Create Translation Files ```bash # Create the directory mkdir -p public/locales/fr -# Copy the English template +# Copy the English templates cp public/locales/en/common.json public/locales/fr/common.json +cp public/locales/en/tools.json public/locales/fr/tools.json ``` -### Step 2: Translate the JSON File +### Step 2: Translate the JSON Files Open `public/locales/fr/common.json` and translate all the values: @@ -95,27 +99,66 @@ Open `public/locales/fr/common.json` and translate all the values: "accueil": "Accueil" ``` +Then do the same for `public/locales/fr/tools.json` to translate all tool names and descriptions. + ### Step 3: Register the Language Edit `src/js/i18n/i18n.ts`: ```typescript // Add 'fr' to supported languages -export const supportedLanguages = ['en', 'de', 'fr'] as const; +export const supportedLanguages = ['en', 'de', 'es', 'fr', 'zh', 'vi'] as const; export type SupportedLanguage = (typeof supportedLanguages)[number]; // Add French display name export const languageNames: Record = { en: 'English', de: 'Deutsch', + es: 'Español', fr: 'Français', // ← Add this + zh: '中文', + vi: 'Tiếng Việt', }; ``` -### Step 4: Test Your Translation +Also update the `getLanguageFromUrl` function in the same file: + +```typescript +export const getLanguageFromUrl = (): SupportedLanguage => { + const path = window.location.pathname; + const langMatch = path.match(/^\/(en|de|es|fr|zh|vi)(?:\/|$)/); // ← Add 'fr' here + // ... rest of the function +}; +``` + +### Step 4: Update Vite Configuration + +Edit `vite.config.ts` to add French to the routing middleware: + +```typescript +function pagesRewritePlugin(): Plugin { + return { + name: 'pages-rewrite', + configureServer(server) { + server.middlewares.use((req, res, next) => { + const url = req.url?.split('?')[0] || ''; + + // Add 'fr' to this regex pattern + const langMatch = url.match(/^\/(en|de|es|fr|zh|vi)(\/.*)?$/); + // ... rest of the middleware + }); + }, + }; +} +``` + +⚠️ **Important**: This step is critical! Without updating the Vite config, you'll get 404 errors when trying to access French pages. + +### Step 5: Restart and Test Your Translation ```bash -# Start the dev server +# Stop the dev server (Ctrl+C) +# Start it again npm run dev # Visit the French version @@ -415,14 +458,34 @@ SyntaxError: Unexpected token } in JSON at position 1234 **Solution:** Make sure you added the language to both arrays in `i18n.ts`: ```typescript -export const supportedLanguages = ['en', 'de', 'fr']; // ← Add here +export const supportedLanguages = ['en', 'de', 'es', 'fr', 'zh', 'vi']; // ← Add here export const languageNames = { en: 'English', de: 'Deutsch', + es: 'Español', fr: 'Français', // ← And here + zh: '中文', + vi: 'Tiếng Việt', }; ``` +### Issue: 404 Error When Accessing Language Pages + +**Symptoms:** +Visiting `http://localhost:5173/fr/about.html` shows a 404 error page. + +**Solution:** +You need to update `vite.config.ts` to include your language code in the routing regex: +```typescript +// In the pagesRewritePlugin function +const langMatch = url.match(/^\/(en|de|es|fr|zh|vi)(\/.*)?$/); // ← Add your language code +``` + +After updating, restart the dev server: +```bash +npm run dev +``` + --- ## File Checklist @@ -430,11 +493,14 @@ export const languageNames = { When adding a new language, make sure these files are updated: - [ ] `public/locales/{lang}/common.json` - Main translation file -- [ ] `src/js/i18n/i18n.ts` - Add to `supportedLanguages` and `languageNames` +- [ ] `public/locales/{lang}/tools.json` - Tools translation file +- [ ] `src/js/i18n/i18n.ts` - Add to `supportedLanguages`, `languageNames`, and `getLanguageFromUrl` regex +- [ ] `vite.config.ts` - Add language code to routing regex in `pagesRewritePlugin` - [ ] Test all pages: homepage, about, contact, FAQ, tool pages - [ ] Test settings modal and shortcuts - [ ] Test language switcher in footer - [ ] Verify URL routing works (`/{lang}/`) +- [ ] Test that all tools load correctly --- @@ -474,9 +540,11 @@ Current translation coverage: |----------|------|--------|------------| | English | `en` | ✅ Complete | Core team | | German | `de` | 🚧 In Progress | Core team | +| Spanish | `es` | ✅ Complete | Community | +| Chinese | `zh` | ✅ Complete | Community | | Vietnamese | `vi` | ✅ Complete | Community | | Your Language | `??` | 🚧 In Progress | You? | --- -**Last Updated**: December 2025 +**Last Updated**: January 2026 diff --git a/public/locales/es/common.json b/public/locales/es/common.json new file mode 100644 index 0000000..a502855 --- /dev/null +++ b/public/locales/es/common.json @@ -0,0 +1,323 @@ +{ + "nav": { + "home": "Inicio", + "about": "Acerca de", + "contact": "Contacto", + "licensing": "Licencias", + "allTools": "Todas las Herramientas", + "openMainMenu": "Abrir menú principal", + "language": "Idioma" + }, + "donation": { + "message": "¿Te encanta BentoPDF? ¡Ayúdanos a mantenerlo gratis y de código abierto!", + "button": "Donar" + }, + "hero": { + "title": "El", + "pdfToolkit": "Kit de Herramientas PDF", + "builtForPrivacy": "diseñado para la privacidad", + "noSignups": "Sin Registro", + "unlimitedUse": "Uso Ilimitado", + "worksOffline": "Funciona Sin Conexión", + "startUsing": "Comenzar a Usar Ahora" + }, + "usedBy": { + "title": "Usado por empresas y personas que trabajan en" + }, + "features": { + "title": "¿Por qué elegir", + "bentoPdf": "BentoPDF?", + "noSignup": { + "title": "Sin Registro", + "description": "Comienza al instante, sin cuentas ni correos electrónicos." + }, + "noUploads": { + "title": "Sin Cargas", + "description": "100% del lado del cliente, tus archivos nunca salen de tu dispositivo." + }, + "foreverFree": { + "title": "Gratis para Siempre", + "description": "Todas las herramientas, sin pruebas, sin restricciones de pago." + }, + "noLimits": { + "title": "Sin Límites", + "description": "Usa tanto como quieras, sin límites ocultos." + }, + "batchProcessing": { + "title": "Procesamiento por Lotes", + "description": "Maneja PDFs ilimitados de una sola vez." + }, + "lightningFast": { + "title": "Ultrarrápido", + "description": "Procesa PDFs al instante, sin esperas ni retrasos." + } + }, + "tools": { + "title": "Comienza con", + "toolsLabel": "Herramientas", + "subtitle": "Haz clic en una herramienta para abrir el cargador de archivos", + "searchPlaceholder": "Buscar una herramienta (ej., 'dividir', 'organizar'...)", + "backToTools": "Volver a Herramientas", + "firstLoadNotice": "La primera carga toma un momento mientras descargamos nuestro motor de conversión. Después de eso, todas las cargas serán instantáneas." + }, + "upload": { + "clickToSelect": "Haz clic para seleccionar un archivo", + "orDragAndDrop": "o arrastra y suelta", + "pdfOrImages": "PDFs o Imágenes", + "filesNeverLeave": "Tus archivos nunca salen de tu dispositivo.", + "addMore": "Agregar Más Archivos", + "clearAll": "Limpiar Todo" + }, + "loader": { + "processing": "Procesando..." + }, + "alert": { + "title": "Alerta", + "ok": "OK" + }, + "preview": { + "title": "Vista Previa del Documento", + "downloadAsPdf": "Descargar como PDF", + "close": "Cerrar" + }, + "settings": { + "title": "Configuración", + "shortcuts": "Atajos", + "preferences": "Preferencias", + "displayPreferences": "Preferencias de Visualización", + "searchShortcuts": "Buscar atajos...", + "shortcutsInfo": "Mantén presionadas las teclas para establecer un atajo. Los cambios se guardan automáticamente.", + "shortcutsWarning": "⚠️ Evita los atajos comunes del navegador (Cmd/Ctrl+W, Cmd/Ctrl+T, Cmd/Ctrl+N, etc.) ya que pueden no funcionar de manera confiable.", + "import": "Importar", + "export": "Exportar", + "resetToDefaults": "Restaurar Valores Predeterminados", + "fullWidthMode": "Modo de Ancho Completo", + "fullWidthDescription": "Usa el ancho completo de la pantalla para todas las herramientas en lugar de un contenedor centrado", + "settingsAutoSaved": "La configuración se guarda automáticamente", + "clickToSet": "Haz clic para establecer", + "pressKeys": "Presiona teclas...", + "warnings": { + "alreadyInUse": "Atajo Ya en Uso", + "assignedTo": "ya está asignado a:", + "chooseDifferent": "Por favor elige un atajo diferente.", + "reserved": "Advertencia de Atajo Reservado", + "commonlyUsed": "se usa comúnmente para:", + "unreliable": "Este atajo puede no funcionar de manera confiable o puede entrar en conflicto con el comportamiento del navegador/sistema.", + "useAnyway": "¿Quieres usarlo de todos modos?", + "resetTitle": "Restablecer Atajos", + "resetMessage": "¿Estás seguro de que quieres restablecer todos los atajos a los valores predeterminados?

Esta acción no se puede deshacer.", + "importSuccessTitle": "Importación Exitosa", + "importSuccessMessage": "¡Atajos importados exitosamente!", + "importFailTitle": "Importación Fallida", + "importFailMessage": "Error al importar atajos. Formato de archivo inválido." + } + }, + "warning": { + "title": "Advertencia", + "cancel": "Cancelar", + "proceed": "Continuar" + }, + "compliance": { + "title": "Tus datos nunca salen de tu dispositivo", + "weKeep": "Mantenemos", + "yourInfoSafe": "tu información segura", + "byFollowingStandards": "siguiendo estándares de seguridad globales.", + "processingLocal": "Todo el procesamiento ocurre localmente en tu dispositivo.", + "gdpr": { + "title": "Cumplimiento GDPR", + "description": "Protege los datos personales y la privacidad de las personas dentro de la Unión Europea." + }, + "ccpa": { + "title": "Cumplimiento CCPA", + "description": "Otorga a los residentes de California derechos sobre cómo se recopila, usa y comparte su información personal." + }, + "hipaa": { + "title": "Cumplimiento HIPAA", + "description": "Establece salvaguardas para el manejo de información de salud sensible en el sistema de atención médica de Estados Unidos." + } + }, + "faq": { + "title": "Preguntas", + "questions": "Frecuentes", + "isFree": { + "question": "¿BentoPDF es realmente gratis?", + "answer": "Sí, absolutamente. Todas las herramientas en BentoPDF son 100% gratuitas, sin límites de archivos, sin registro y sin marcas de agua. Creemos que todos merecen acceso a herramientas PDF simples y potentes sin un muro de pago." + }, + "areFilesSecure": { + "question": "¿Mis archivos están seguros? ¿Dónde se procesan?", + "answer": "Tus archivos están lo más seguros posible porque nunca salen de tu computadora. Todo el procesamiento ocurre directamente en tu navegador web (del lado del cliente). Nunca cargamos tus archivos a un servidor, por lo que mantienes total privacidad y control sobre tus documentos." + }, + "platforms": { + "question": "¿Funciona en Mac, Windows y Móvil?", + "answer": "¡Sí! Dado que BentoPDF se ejecuta completamente en tu navegador, funciona en cualquier sistema operativo con un navegador web moderno, incluyendo Windows, macOS, Linux, iOS y Android." + }, + "gdprCompliant": { + "question": "¿BentoPDF cumple con GDPR?", + "answer": "Sí. BentoPDF cumple completamente con GDPR. Dado que todo el procesamiento de archivos ocurre localmente en tu navegador y nunca recopilamos ni transmitimos tus archivos a ningún servidor, no tenemos acceso a tus datos. Esto garantiza que siempre tengas el control de tus documentos." + }, + "dataStorage": { + "question": "¿Almacenan o rastrean alguno de mis archivos?", + "answer": "No. Nunca almacenamos, rastreamos ni registramos tus archivos. Todo lo que haces en BentoPDF ocurre en la memoria de tu navegador y desaparece una vez que cierras la página. No hay cargas, no hay registros de historial y no hay servidores involucrados." + }, + "different": { + "question": "¿Qué hace que BentoPDF sea diferente de otras herramientas PDF?", + "answer": "La mayoría de las herramientas PDF cargan tus archivos a un servidor para procesarlos. BentoPDF nunca hace eso. Utilizamos tecnología web moderna y segura para procesar tus archivos directamente en tu navegador. Esto significa un rendimiento más rápido, mayor privacidad y total tranquilidad." + }, + "browserBased": { + "question": "¿Cómo me mantiene seguro el procesamiento basado en navegador?", + "answer": "Al ejecutarse completamente dentro de tu navegador, BentoPDF garantiza que tus archivos nunca salgan de tu dispositivo. Esto elimina los riesgos de hackeos de servidores, violaciones de datos o accesos no autorizados. Tus archivos siguen siendo tuyos, siempre." + }, + "analytics": { + "question": "¿Usan cookies o análisis para rastrearme?", + "answer": "Nos preocupamos por tu privacidad. BentoPDF no rastrea información personal. Usamos Simple Analytics únicamente para ver recuentos de visitas anónimas. Esto significa que podemos saber cuántos usuarios visitan nuestro sitio, pero nunca sabemos quién eres. Simple Analytics cumple completamente con GDPR y respeta tu privacidad." + } + }, + "testimonials": { + "title": "Lo que Nuestros", + "users": "Usuarios", + "say": "Dicen" + }, + "support": { + "title": "¿Te Gusta Mi Trabajo?", + "description": "BentoPDF es un proyecto de pasión, creado para proporcionar un kit de herramientas PDF gratuito, privado y potente para todos. Si te resulta útil, considera apoyar su desarrollo. ¡Cada café ayuda!", + "buyMeCoffee": "Cómprame un Café" + }, + "footer": { + "copyright": "© 2025 BentoPDF. Todos los derechos reservados.", + "version": "Versión", + "company": "Empresa", + "aboutUs": "Acerca de Nosotros", + "faqLink": "Preguntas Frecuentes", + "contactUs": "Contáctanos", + "legal": "Legal", + "termsAndConditions": "Términos y Condiciones", + "privacyPolicy": "Política de Privacidad", + "followUs": "Síguenos" + }, + "merge": { + "title": "Fusionar PDFs", + "description": "Combina archivos completos o selecciona páginas específicas para fusionar en un nuevo documento.", + "fileMode": "Modo Archivo", + "pageMode": "Modo Página", + "howItWorks": "Cómo funciona:", + "fileModeInstructions": [ + "Haz clic y arrastra el ícono para cambiar el orden de los archivos.", + "En el cuadro \"Páginas\" para cada archivo, puedes especificar rangos (ej., \"1-3, 5\") para fusionar solo esas páginas.", + "Deja el cuadro \"Páginas\" en blanco para incluir todas las páginas de ese archivo." + ], + "pageModeInstructions": [ + "Todas las páginas de tus PDFs cargados se muestran a continuación.", + "Simplemente arrastra y suelta las miniaturas de páginas individuales para crear el orden exacto que deseas para tu nuevo archivo." + ], + "mergePdfs": "Fusionar PDFs" + }, + "common": { + "page": "Página", + "pages": "Páginas", + "of": "de", + "download": "Descargar", + "cancel": "Cancelar", + "save": "Guardar", + "delete": "Eliminar", + "edit": "Editar", + "add": "Agregar", + "remove": "Remover", + "loading": "Cargando...", + "error": "Error", + "success": "Éxito", + "file": "Archivo", + "files": "Archivos" + }, + "about": { + "hero": { + "title": "Creemos que las herramientas PDF deben ser", + "subtitle": "rápidas, privadas y gratuitas.", + "noCompromises": "Sin compromisos." + }, + "mission": { + "title": "Nuestra Misión", + "description": "Proporcionar la caja de herramientas PDF más completa que respete tu privacidad y nunca pida pago. Creemos que las herramientas de documentos esenciales deben ser accesibles para todos, en todas partes, sin barreras." + }, + "philosophy": { + "label": "Nuestra Filosofía Central", + "title": "Privacidad Primero. Siempre.", + "description": "En una era donde los datos son una mercancía, adoptamos un enfoque diferente. Todo el procesamiento de las herramientas de Bentopdf ocurre localmente en tu navegador. Esto significa que tus archivos nunca tocan nuestros servidores, nunca vemos tus documentos y no rastreamos lo que haces. Tus documentos permanecen completa e inequívocamente privados. No es solo una característica; es nuestra base." + }, + "whyBentopdf": { + "title": "Por qué", + "speed": { + "title": "Diseñado para la Velocidad", + "description": "Sin esperar cargas o descargas a un servidor. Al procesar archivos directamente en tu navegador usando tecnologías web modernas como WebAssembly, ofrecemos una velocidad incomparable para todas nuestras herramientas." + }, + "free": { + "title": "Completamente Gratis", + "description": "Sin pruebas, sin suscripciones, sin tarifas ocultas y sin funciones \"premium\" retenidas como rehenes. Creemos que las herramientas PDF potentes deben ser una utilidad pública, no un centro de ganancias." + }, + "noAccount": { + "title": "No Requiere Cuenta", + "description": "Comienza a usar cualquier herramienta de inmediato. No necesitamos tu correo electrónico, una contraseña o cualquier información personal. Tu flujo de trabajo debe ser sin fricciones y anónimo." + }, + "openSource": { + "title": "Espíritu de Código Abierto", + "description": "Construido con transparencia en mente. Aprovechamos increíbles bibliotecas de código abierto como PDF-lib y PDF.js, y creemos en el esfuerzo impulsado por la comunidad para hacer que las herramientas potentes sean accesibles para todos." + } + }, + "cta": { + "title": "¿Listo para comenzar?", + "description": "Únete a miles de usuarios que confían en Bentopdf para sus necesidades diarias de documentos. Experimenta la diferencia que la privacidad y el rendimiento pueden hacer.", + "button": "Explorar Todas las Herramientas" + } + }, + "contact": { + "title": "Ponte en Contacto", + "subtitle": "Nos encantaría saber de ti. Ya sea que tengas una pregunta, comentario o solicitud de función, no dudes en comunicarte.", + "email": "Puedes contactarnos directamente por correo electrónico en:" + }, + "licensing": { + "title": "Licencias para", + "subtitle": "Elige la licencia que se ajuste a tus necesidades." + }, + "multiTool": { + "uploadPdfs": "Cargar PDFs", + "upload": "Cargar", + "addBlankPage": "Agregar Página en Blanco", + "edit": "Editar:", + "undo": "Deshacer", + "redo": "Rehacer", + "reset": "Restablecer", + "selection": "Selección:", + "selectAll": "Seleccionar Todo", + "deselectAll": "Deseleccionar Todo", + "rotate": "Rotar:", + "rotateLeft": "Izquierda", + "rotateRight": "Derecha", + "transform": "Transformar:", + "duplicate": "Duplicar", + "split": "Dividir", + "clear": "Limpiar:", + "delete": "Eliminar", + "download": "Descargar:", + "downloadSelected": "Descargar Seleccionados", + "exportPdf": "Exportar PDF", + "uploadPdfFiles": "Seleccionar Archivos PDF", + "dragAndDrop": "Arrastra y suelta archivos PDF aquí, o haz clic para seleccionar", + "selectFiles": "Seleccionar Archivos", + "renderingPages": "Renderizando páginas...", + "actions": { + "duplicatePage": "Duplicar esta página", + "deletePage": "Eliminar esta página", + "insertPdf": "Insertar PDF después de esta página", + "toggleSplit": "Alternar división después de esta página" + }, + "pleaseWait": "Por Favor Espera", + "pagesRendering": "Las páginas aún se están renderizando. Por favor espera...", + "noPagesSelected": "No Se Seleccionaron Páginas", + "selectOnePage": "Por favor selecciona al menos una página para descargar.", + "noPages": "Sin Páginas", + "noPagesToExport": "No hay páginas para exportar.", + "renderingTitle": "Renderizando vistas previas de páginas", + "errorRendering": "Error al renderizar miniaturas de páginas", + "error": "Error", + "failedToLoad": "Error al cargar" + } +} diff --git a/public/locales/es/tools.json b/public/locales/es/tools.json new file mode 100644 index 0000000..7839a2e --- /dev/null +++ b/public/locales/es/tools.json @@ -0,0 +1,492 @@ +{ + "categories": { + "popularTools": "Herramientas Populares", + "editAnnotate": "Editar y Anotar", + "convertToPdf": "Convertir a PDF", + "convertFromPdf": "Convertir desde PDF", + "organizeManage": "Organizar y Gestionar", + "optimizeRepair": "Optimizar y Reparar", + "securePdf": "Asegurar PDF" + }, + "pdfMultiTool": { + "name": "Multiherramienta PDF", + "subtitle": "Fusionar, Dividir, Organizar, Eliminar, Rotar, Agregar Páginas en Blanco, Extraer y Duplicar en una interfaz unificada." + }, + "mergePdf": { + "name": "Fusionar PDF", + "subtitle": "Combina múltiples PDFs en un solo archivo. Preserva Marcadores." + }, + "splitPdf": { + "name": "Dividir PDF", + "subtitle": "Extrae un rango de páginas en un nuevo PDF." + }, + "compressPdf": { + "name": "Comprimir PDF", + "subtitle": "Reduce el tamaño de archivo de tu PDF.", + "algorithmLabel": "Algoritmo de Compresión", + "condense": "Condensar (Recomendado)", + "photon": "Photon (Para PDFs con Muchas Fotos)", + "condenseInfo": "Condensar usa compresión avanzada: elimina peso muerto, optimiza imágenes, reduce fuentes. Mejor para la mayoría de PDFs.", + "photonInfo": "Photon convierte páginas en imágenes. Úsalo para PDFs con muchas fotos/escaneados.", + "photonWarning": "Advertencia: El texto dejará de ser seleccionable y los enlaces dejarán de funcionar.", + "levelLabel": "Nivel de Compresión", + "light": "Ligero (Preservar Calidad)", + "balanced": "Equilibrado (Recomendado)", + "aggressive": "Agresivo (Archivos Más Pequeños)", + "extreme": "Extremo (Compresión Máxima)", + "grayscale": "Convertir a Escala de Grises", + "grayscaleHint": "Reduce el tamaño del archivo eliminando información de color", + "customSettings": "Configuración Personalizada", + "customSettingsHint": "Ajusta los parámetros de compresión:", + "outputQuality": "Calidad de Salida", + "resizeImagesTo": "Redimensionar Imágenes a", + "onlyProcessAbove": "Solo Procesar Arriba de", + "removeMetadata": "Eliminar metadatos", + "subsetFonts": "Reducir fuentes (eliminar glifos no usados)", + "removeThumbnails": "Eliminar miniaturas incrustadas", + "compressButton": "Comprimir PDF" + }, + "pdfEditor": { + "name": "Editor PDF", + "subtitle": "Anotar, resaltar, redactar, comentar, agregar formas/imágenes, buscar y ver PDFs." + }, + "jpgToPdf": { + "name": "JPG a PDF", + "subtitle": "Crea un PDF desde imágenes JPG, JPEG y JPEG2000 (JP2/JPX)." + }, + "signPdf": { + "name": "Firmar PDF", + "subtitle": "Dibuja, escribe o carga tu firma." + }, + "cropPdf": { + "name": "Recortar PDF", + "subtitle": "Recorta los márgenes de cada página en tu PDF." + }, + "extractPages": { + "name": "Extraer Páginas", + "subtitle": "Guarda una selección de páginas como nuevos archivos." + }, + "duplicateOrganize": { + "name": "Duplicar y Organizar", + "subtitle": "Duplica, reordena y elimina páginas." + }, + "deletePages": { + "name": "Eliminar Páginas", + "subtitle": "Elimina páginas específicas de tu documento." + }, + "editBookmarks": { + "name": "Editar Marcadores", + "subtitle": "Agrega, edita, importa, elimina y extrae marcadores PDF." + }, + "tableOfContents": { + "name": "Tabla de Contenidos", + "subtitle": "Genera una página de tabla de contenidos desde los marcadores PDF." + }, + "pageNumbers": { + "name": "Números de Página", + "subtitle": "Inserta números de página en tu documento." + }, + "addWatermark": { + "name": "Agregar Marca de Agua", + "subtitle": "Estampa texto o una imagen sobre tus páginas PDF." + }, + "headerFooter": { + "name": "Encabezado y Pie de Página", + "subtitle": "Agrega texto en la parte superior e inferior de las páginas." + }, + "invertColors": { + "name": "Invertir Colores", + "subtitle": "Crea una versión en \"modo oscuro\" de tu PDF." + }, + "backgroundColor": { + "name": "Color de Fondo", + "subtitle": "Cambia el color de fondo de tu PDF." + }, + "changeTextColor": { + "name": "Cambiar Color de Texto", + "subtitle": "Cambia el color del texto en tu PDF." + }, + "addStamps": { + "name": "Agregar Sellos", + "subtitle": "Agrega sellos de imagen a tu PDF usando la barra de herramientas de anotación.", + "usernameLabel": "Nombre de Usuario del Sello", + "usernamePlaceholder": "Ingresa tu nombre (para sellos)", + "usernameHint": "Este nombre aparecerá en los sellos que crees." + }, + "removeAnnotations": { + "name": "Eliminar Anotaciones", + "subtitle": "Elimina comentarios, resaltados y enlaces." + }, + "pdfFormFiller": { + "name": "Rellenar Formularios PDF", + "subtitle": "Rellena formularios directamente en el navegador. También soporta formularios XFA." + }, + "createPdfForm": { + "name": "Crear Formulario PDF", + "subtitle": "Crea formularios PDF rellenables con campos de texto arrastrables." + }, + "removeBlankPages": { + "name": "Eliminar Páginas en Blanco", + "subtitle": "Detecta y elimina automáticamente páginas en blanco." + }, + "imageToPdf": { + "name": "Imágenes a PDF", + "subtitle": "Convierte JPG, PNG, BMP, GIF, TIFF, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2, PSD, SVG, HEIC, WebP a PDF." + }, + "pngToPdf": { + "name": "PNG a PDF", + "subtitle": "Crea un PDF desde una o más imágenes PNG." + }, + "webpToPdf": { + "name": "WebP a PDF", + "subtitle": "Crea un PDF desde una o más imágenes WebP." + }, + "svgToPdf": { + "name": "SVG a PDF", + "subtitle": "Crea un PDF desde una o más imágenes SVG." + }, + "bmpToPdf": { + "name": "BMP a PDF", + "subtitle": "Crea un PDF desde una o más imágenes BMP." + }, + "heicToPdf": { + "name": "HEIC a PDF", + "subtitle": "Crea un PDF desde una o más imágenes HEIC." + }, + "tiffToPdf": { + "name": "TIFF a PDF", + "subtitle": "Crea un PDF desde una o más imágenes TIFF." + }, + "textToPdf": { + "name": "Texto a PDF", + "subtitle": "Convierte un archivo de texto plano en un PDF." + }, + "jsonToPdf": { + "name": "JSON a PDF", + "subtitle": "Convierte archivos JSON a formato PDF." + }, + "pdfToJpg": { + "name": "PDF a JPG", + "subtitle": "Convierte cada página PDF en una imagen JPG." + }, + "pdfToPng": { + "name": "PDF a PNG", + "subtitle": "Convierte cada página PDF en una imagen PNG." + }, + "pdfToWebp": { + "name": "PDF a WebP", + "subtitle": "Convierte cada página PDF en una imagen WebP." + }, + "pdfToBmp": { + "name": "PDF a BMP", + "subtitle": "Convierte cada página PDF en una imagen BMP." + }, + "pdfToTiff": { + "name": "PDF a TIFF", + "subtitle": "Convierte cada página PDF en una imagen TIFF." + }, + "pdfToGreyscale": { + "name": "PDF a Escala de Grises", + "subtitle": "Convierte todos los colores a blanco y negro." + }, + "pdfToJson": { + "name": "PDF a JSON", + "subtitle": "Convierte archivos PDF a formato JSON." + }, + "ocrPdf": { + "name": "OCR PDF", + "subtitle": "Hace que un PDF sea buscable y copiable." + }, + "alternateMix": { + "name": "Alternar y Mezclar Páginas", + "subtitle": "Fusiona PDFs alternando páginas de cada PDF. Preserva Marcadores." + }, + "addAttachments": { + "name": "Agregar Adjuntos", + "subtitle": "Incrusta uno o más archivos en tu PDF." + }, + "extractAttachments": { + "name": "Extraer Adjuntos", + "subtitle": "Extrae todos los archivos incrustados de PDF(s) como un ZIP." + }, + "editAttachments": { + "name": "Editar Adjuntos", + "subtitle": "Ve o elimina adjuntos en tu PDF." + }, + "dividePages": { + "name": "Dividir Páginas", + "subtitle": "Divide páginas horizontal o verticalmente." + }, + "addBlankPage": { + "name": "Agregar Página en Blanco", + "subtitle": "Inserta una página vacía en cualquier lugar de tu PDF." + }, + "reversePages": { + "name": "Invertir Páginas", + "subtitle": "Invierte el orden de todas las páginas en tu documento." + }, + "rotatePdf": { + "name": "Rotar PDF", + "subtitle": "Gira páginas en incrementos de 90 grados." + }, + "rotateCustom": { + "name": "Rotar por Grados Personalizados", + "subtitle": "Rota páginas por cualquier ángulo personalizado." + }, + "nUpPdf": { + "name": "N-Up PDF", + "subtitle": "Organiza múltiples páginas en una sola hoja." + }, + "combineToSinglePage": { + "name": "Combinar en Una Sola Página", + "subtitle": "Une todas las páginas en un desplazamiento continuo." + }, + "viewMetadata": { + "name": "Ver Metadatos", + "subtitle": "Inspecciona las propiedades ocultas de tu PDF." + }, + "editMetadata": { + "name": "Editar Metadatos", + "subtitle": "Cambia el autor, título y otras propiedades." + }, + "pdfsToZip": { + "name": "PDFs a ZIP", + "subtitle": "Empaqueta múltiples archivos PDF en un archivo ZIP." + }, + "comparePdfs": { + "name": "Comparar PDFs", + "subtitle": "Compara dos PDFs lado a lado." + }, + "posterizePdf": { + "name": "Posterizar PDF", + "subtitle": "Divide una página grande en múltiples páginas más pequeñas." + }, + "fixPageSize": { + "name": "Fijar Tamaño de Página", + "subtitle": "Estandariza todas las páginas a un tamaño uniforme." + }, + "linearizePdf": { + "name": "Linealizar PDF", + "subtitle": "Optimiza el PDF para visualización web rápida." + }, + "pageDimensions": { + "name": "Dimensiones de Página", + "subtitle": "Analiza el tamaño, orientación y unidades de página." + }, + "removeRestrictions": { + "name": "Eliminar Restricciones", + "subtitle": "Elimina la protección por contraseña y las restricciones de seguridad asociadas con archivos PDF firmados digitalmente." + }, + "repairPdf": { + "name": "Reparar PDF", + "subtitle": "Recupera datos de archivos PDF corruptos o dañados." + }, + "encryptPdf": { + "name": "Cifrar PDF", + "subtitle": "Bloquea tu PDF agregando una contraseña." + }, + "sanitizePdf": { + "name": "Sanear PDF", + "subtitle": "Elimina metadatos, anotaciones, scripts y más." + }, + "decryptPdf": { + "name": "Descifrar PDF", + "subtitle": "Desbloquea PDF eliminando la protección por contraseña." + }, + "flattenPdf": { + "name": "Aplanar PDF", + "subtitle": "Hace que los campos de formulario y las anotaciones no sean editables." + }, + "removeMetadata": { + "name": "Eliminar Metadatos", + "subtitle": "Elimina datos ocultos de tu PDF." + }, + "changePermissions": { + "name": "Cambiar Permisos", + "subtitle": "Establece o cambia los permisos de usuario en un PDF." + }, + "odtToPdf": { + "name": "ODT a PDF", + "subtitle": "Convierte archivos OpenDocument Text a formato PDF. Soporta múltiples archivos.", + "acceptedFormats": "Archivos ODT", + "convertButton": "Convertir a PDF" + }, + "csvToPdf": { + "name": "CSV a PDF", + "subtitle": "Convierte archivos de hoja de cálculo CSV a formato PDF. Soporta múltiples archivos.", + "acceptedFormats": "Archivos CSV", + "convertButton": "Convertir a PDF" + }, + "rtfToPdf": { + "name": "RTF a PDF", + "subtitle": "Convierte documentos Rich Text Format a PDF. Soporta múltiples archivos.", + "acceptedFormats": "Archivos RTF", + "convertButton": "Convertir a PDF" + }, + "wordToPdf": { + "name": "Word a PDF", + "subtitle": "Convierte documentos Word (DOCX, DOC, ODT, RTF) a formato PDF. Soporta múltiples archivos.", + "acceptedFormats": "Archivos DOCX, DOC, ODT, RTF", + "convertButton": "Convertir a PDF" + }, + "excelToPdf": { + "name": "Excel a PDF", + "subtitle": "Convierte hojas de cálculo Excel (XLSX, XLS, ODS, CSV) a formato PDF. Soporta múltiples archivos.", + "acceptedFormats": "Archivos XLSX, XLS, ODS, CSV", + "convertButton": "Convertir a PDF" + }, + "powerpointToPdf": { + "name": "PowerPoint a PDF", + "subtitle": "Convierte presentaciones PowerPoint (PPTX, PPT, ODP) a formato PDF. Soporta múltiples archivos.", + "acceptedFormats": "Archivos PPTX, PPT, ODP", + "convertButton": "Convertir a PDF" + }, + "markdownToPdf": { + "name": "Markdown a PDF", + "subtitle": "Escribe o pega Markdown y expórtalo como un PDF bellamente formateado.", + "paneMarkdown": "Markdown", + "panePreview": "Vista Previa", + "btnUpload": "Cargar", + "btnSyncScroll": "Sincronizar Desplazamiento", + "btnSettings": "Configuración", + "btnExportPdf": "Exportar PDF", + "settingsTitle": "Configuración de Markdown", + "settingsPreset": "Predefinido", + "presetDefault": "Predeterminado (similar a GFM)", + "presetCommonmark": "CommonMark (estricto)", + "presetZero": "Mínimo (sin funciones)", + "settingsOptions": "Opciones de Markdown", + "optAllowHtml": "Permitir etiquetas HTML", + "optBreaks": "Convertir saltos de línea a
", + "optLinkify": "Auto-convertir URLs a enlaces", + "optTypographer": "Tipógrafo (comillas inteligentes, etc.)" + }, + "pdfBooklet": { + "name": "Folleto PDF", + "subtitle": "Reorganiza páginas para impresión de folleto a doble cara. Dobla y engrapa para crear un folleto.", + "howItWorks": "Cómo funciona:", + "step1": "Carga un archivo PDF.", + "step2": "Las páginas se reorganizarán en orden de folleto.", + "step3": "Imprime a doble cara, voltea por el borde corto, dobla y engrapa.", + "paperSize": "Tamaño de Papel", + "orientation": "Orientación", + "portrait": "Vertical", + "landscape": "Horizontal", + "pagesPerSheet": "Páginas por Hoja", + "createBooklet": "Crear Folleto", + "processing": "Procesando...", + "pageCount": "El recuento de páginas se rellenará a múltiplo de 4 si es necesario." + }, + "xpsToPdf": { + "name": "XPS a PDF", + "subtitle": "Convierte documentos XPS/OXPS a formato PDF. Soporta múltiples archivos.", + "acceptedFormats": "Archivos XPS, OXPS", + "convertButton": "Convertir a PDF" + }, + "mobiToPdf": { + "name": "MOBI a PDF", + "subtitle": "Convierte e-books MOBI a formato PDF. Soporta múltiples archivos.", + "acceptedFormats": "Archivos MOBI", + "convertButton": "Convertir a PDF" + }, + "epubToPdf": { + "name": "EPUB a PDF", + "subtitle": "Convierte e-books EPUB a formato PDF. Soporta múltiples archivos.", + "acceptedFormats": "Archivos EPUB", + "convertButton": "Convertir a PDF" + }, + "fb2ToPdf": { + "name": "FB2 a PDF", + "subtitle": "Convierte e-books FictionBook (FB2) a formato PDF. Soporta múltiples archivos.", + "acceptedFormats": "Archivos FB2", + "convertButton": "Convertir a PDF" + }, + "cbzToPdf": { + "name": "CBZ a PDF", + "subtitle": "Convierte archivos de cómics (CBZ/CBR) a formato PDF. Soporta múltiples archivos.", + "acceptedFormats": "Archivos CBZ, CBR", + "convertButton": "Convertir a PDF" + }, + "wpdToPdf": { + "name": "WPD a PDF", + "subtitle": "Convierte documentos WordPerfect (WPD) a formato PDF. Soporta múltiples archivos.", + "acceptedFormats": "Archivos WPD", + "convertButton": "Convertir a PDF" + }, + "wpsToPdf": { + "name": "WPS a PDF", + "subtitle": "Convierte documentos WPS Office a formato PDF. Soporta múltiples archivos.", + "acceptedFormats": "Archivos WPS", + "convertButton": "Convertir a PDF" + }, + "xmlToPdf": { + "name": "XML a PDF", + "subtitle": "Convierte documentos XML a formato PDF. Soporta múltiples archivos.", + "acceptedFormats": "Archivos XML", + "convertButton": "Convertir a PDF" + }, + "pagesToPdf": { + "name": "Pages a PDF", + "subtitle": "Convierte documentos Apple Pages a formato PDF. Soporta múltiples archivos.", + "acceptedFormats": "Archivos Pages", + "convertButton": "Convertir a PDF" + }, + "odgToPdf": { + "name": "ODG a PDF", + "subtitle": "Convierte archivos OpenDocument Graphics (ODG) a formato PDF. Soporta múltiples archivos.", + "acceptedFormats": "Archivos ODG", + "convertButton": "Convertir a PDF" + }, + "odsToPdf": { + "name": "ODS a PDF", + "subtitle": "Convierte archivos OpenDocument Spreadsheet (ODS) a formato PDF. Soporta múltiples archivos.", + "acceptedFormats": "Archivos ODS", + "convertButton": "Convertir a PDF" + }, + "odpToPdf": { + "name": "ODP a PDF", + "subtitle": "Convierte archivos OpenDocument Presentation (ODP) a formato PDF. Soporta múltiples archivos.", + "acceptedFormats": "Archivos ODP", + "convertButton": "Convertir a PDF" + }, + "pubToPdf": { + "name": "PUB a PDF", + "subtitle": "Convierte archivos Microsoft Publisher (PUB) a formato PDF. Soporta múltiples archivos.", + "acceptedFormats": "Archivos PUB", + "convertButton": "Convertir a PDF" + }, + "vsdToPdf": { + "name": "VSD a PDF", + "subtitle": "Convierte archivos Microsoft Visio (VSD, VSDX) a formato PDF. Soporta múltiples archivos.", + "acceptedFormats": "Archivos VSD, VSDX", + "convertButton": "Convertir a PDF" + }, + "psdToPdf": { + "name": "PSD a PDF", + "subtitle": "Convierte archivos Adobe Photoshop (PSD) a formato PDF. Soporta múltiples archivos.", + "acceptedFormats": "Archivos PSD", + "convertButton": "Convertir a PDF" + }, + "pdfToSvg": { + "name": "PDF a SVG", + "subtitle": "Convierte cada página de un archivo PDF en un gráfico vectorial escalable (SVG) para calidad perfecta a cualquier tamaño." + }, + "extractTables": { + "name": "Extraer Tablas de PDF", + "subtitle": "Extrae tablas de archivos PDF y exporta como CSV, JSON o Markdown." + }, + "pdfToCsv": { + "name": "PDF a CSV", + "subtitle": "Extrae tablas de PDF y convierte a formato CSV." + }, + "pdfToExcel": { + "name": "PDF a Excel", + "subtitle": "Extrae tablas de PDF y convierte a formato Excel (XLSX)." + }, + "pdfToText": { + "name": "PDF a Texto", + "subtitle": "Extrae texto de archivos PDF y guarda como texto plano (.txt). Soporta múltiples archivos.", + "note": "Esta herramienta funciona SOLO con PDFs creados digitalmente. Para documentos escaneados o PDFs basados en imágenes, usa nuestra herramienta OCR PDF en su lugar.", + "convertButton": "Extraer Texto" + } +} diff --git a/src/js/i18n/i18n.ts b/src/js/i18n/i18n.ts index 62eca2b..2793696 100644 --- a/src/js/i18n/i18n.ts +++ b/src/js/i18n/i18n.ts @@ -3,19 +3,20 @@ import LanguageDetector from 'i18next-browser-languagedetector'; import HttpBackend from 'i18next-http-backend'; // Supported languages -export const supportedLanguages = ['en', 'de', 'zh', 'vi'] as const; +export const supportedLanguages = ['en', 'de', 'es', 'zh', 'vi'] as const; export type SupportedLanguage = (typeof supportedLanguages)[number]; export const languageNames: Record = { en: 'English', de: 'Deutsch', + es: 'Español', zh: '中文', vi: 'Tiếng Việt', }; export const getLanguageFromUrl = (): SupportedLanguage => { const path = window.location.pathname; - const langMatch = path.match(/^\/(en|de|zh|vi)(?:\/|$)/); + const langMatch = path.match(/^\/(en|de|es|zh|vi)(?:\/|$)/); if (langMatch && supportedLanguages.includes(langMatch[1] as SupportedLanguage)) { return langMatch[1] as SupportedLanguage; } diff --git a/vite.config.ts b/vite.config.ts index ce8a707..4029d5c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,7 +14,7 @@ function pagesRewritePlugin(): Plugin { server.middlewares.use((req, res, next) => { const url = req.url?.split('?')[0] || ''; - const langMatch = url.match(/^\/(en|de|zh|vi)(\/.*)?$/); + const langMatch = url.match(/^\/(en|de|es|zh|vi)(\/.*)?$/); if (langMatch) { const lang = langMatch[1]; const restOfPath = langMatch[2] || '/'; From 528b14f60449eb9e22b7a582844a6ac7d6fc8524 Mon Sep 17 00:00:00 2001 From: Raul Gonzalez Date: Fri, 2 Jan 2026 07:27:28 -0600 Subject: [PATCH 09/73] Sign ICLA --- ICLA.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ICLA.md b/ICLA.md index c80debb..f7e6207 100644 --- a/ICLA.md +++ b/ICLA.md @@ -85,15 +85,15 @@ This Agreement shall be governed by and construed in accordance with the laws of By submitting a pull request or other Contribution to the Project, and by typing your name and date below (or by signing electronically via CLA Assistant), you agree to the terms of this Individual Contributor License Agreement. -**Full Legal Name:** Stephan Paternotte +**Full Legal Name:** Raul Gonzalez -**GitHub Username:** Stephan-P +**GitHub Username:** raulgcode -**Email Address:** stephan@paternottes.net +**Email Address:** armandourbina@gmail.com -**Date:** 20-12-2025 +**Date:** 02-01-2026 -**Signature:** ___________________________ +**Signature:** Raul Gonzalez --- From 2b0ddfebfeeeace76ef1e69b229c35f80aa59ee2 Mon Sep 17 00:00:00 2001 From: Raul Gonzalez Date: Fri, 2 Jan 2026 07:38:29 -0600 Subject: [PATCH 10/73] fix: add Spanish language support in URL rewrite rules --- nginx.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nginx.conf b/nginx.conf index 56fab7a..8de1d2b 100644 --- a/nginx.conf +++ b/nginx.conf @@ -26,7 +26,7 @@ http { root /usr/share/nginx/html; index index.html; - rewrite ^/(en|de|zh|vi)/(.*)$ /$2 last; + rewrite ^/(en|de|es|zh|vi)/(.*)$ /$2 last; location ~* \.html$ { expires 1h; From f39a29e2cc0ac004415afb46835bcd5dc2a84471 Mon Sep 17 00:00:00 2001 From: Raul Gonzalez Date: Fri, 2 Jan 2026 07:40:12 -0600 Subject: [PATCH 11/73] fix: Rollback changes in ICLA file --- ICLA.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ICLA.md b/ICLA.md index f7e6207..c80debb 100644 --- a/ICLA.md +++ b/ICLA.md @@ -85,15 +85,15 @@ This Agreement shall be governed by and construed in accordance with the laws of By submitting a pull request or other Contribution to the Project, and by typing your name and date below (or by signing electronically via CLA Assistant), you agree to the terms of this Individual Contributor License Agreement. -**Full Legal Name:** Raul Gonzalez +**Full Legal Name:** Stephan Paternotte -**GitHub Username:** raulgcode +**GitHub Username:** Stephan-P -**Email Address:** armandourbina@gmail.com +**Email Address:** stephan@paternottes.net -**Date:** 02-01-2026 +**Date:** 20-12-2025 -**Signature:** Raul Gonzalez +**Signature:** ___________________________ --- From 6e865efb2a605e82568aaf0416c0ec63795ef499 Mon Sep 17 00:00:00 2001 From: Stephan Paternotte Date: Sun, 4 Jan 2026 20:04:16 +0100 Subject: [PATCH 12/73] Update tools.json Included latest additions re. PDF Signature and Validation --- public/locales/nl/tools.json | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/public/locales/nl/tools.json b/public/locales/nl/tools.json index e2a5be9..f565dea 100644 --- a/public/locales/nl/tools.json +++ b/public/locales/nl/tools.json @@ -369,7 +369,7 @@ "step2": "Pagina's worden in brochurevolgorde gerangschikt.", "step3": "Dubbelzijdig afdrukken, omdraaien langs de korte kant, vouwen en nieten.", "paperSize": "Paperformaat", - "orientation": "Ori�ntatie", + "orientation": "Ori뮴atie", "portrait": "Staand", "landscape": "Liggend", "pagesPerSheet": "Pagina's per vel", @@ -488,5 +488,32 @@ "subtitle": "Haal tekst uit PDF-bestanden en sla op als gewone tekst (.txt). Ondersteunt meerdere bestanden.", "note": "Dit hulpmiddel werkt ALLEEN met digitaal gemaakte PDF's. Gebruik voor gescande documenten of op afbeeldingen gebaseerde PDF's in plaats hiervan de OCR PDF-tool.", "convertButton": "Tekst extraheren" + }, + "digitalSignPdf": { + "name": "Digitale handtekening PDF", + "pageTitle": "Digitale handtekening PDF - Cryptografische handtekening toevoegen | BentoPDF", + "subtitle": "Voeg een cryptografische digitale handtekening toe aan je PDF met behulp van X.509-certificaten. Ondersteunt PKCS#12 (.pfx, .p12) en PEM-formaten. Je priv鳬eutel verlaat jouw browser nooit.", + "certificateSection": "Certificaat", + "uploadCert": "Certificaat laden (.pfx, .p12)", + "certPassword": "Certificaatwachtwoord", + "certPasswordPlaceholder": "Voer het wachtwoord van het certificaat in", + "certInfo": "Certificaatinformatie", + "certSubject": "Onderwerp", + "certIssuer": "Uitgever", + "certValidity": "Geldigheid", + "signatureDetails": "Signature Details (Optioneel)", + "reason": "Reden", + "reasonPlaceholder": "bijv., Ik keur dit document goed", + "location": "Plaats", + "locationPlaceholder": "bijv., New York, USA", + "contactInfo": "Contactinformatie", + "contactPlaceholder": "bijv., email@example.com", + "applySignature": "Digitale handtekening toepassen", + "successMessage": "PDF succesvol ondertekend! De handtekening kan in elke PDF-lezer worden geverifieerd." + }, + "validateSignaturePdf": { + "name": "PDF-handtekening valideren", + "pageTitle": "PDF-handtekening valideren - Digitale handtekeningen verifi벥n | BentoPDF", + "subtitle": "Digitale handtekeningen in je PDF-bestanden verifi벥n. Controleer de geldigheid van het certificaat, bekijk de gegevens van de ondertekenaar en bevestig de integriteit van het document. Alle verwerking gebeurt binnen jouw browser." } } From 0491c1118ea9fba3373601e95c3682ecf8abe697 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 18:21:15 +0000 Subject: [PATCH 13/73] build(deps): bump jspdf from 3.0.4 to 4.0.0 Bumps [jspdf](https://github.com/parallax/jsPDF) from 3.0.4 to 4.0.0. - [Release notes](https://github.com/parallax/jsPDF/releases) - [Changelog](https://github.com/parallax/jsPDF/blob/master/RELEASE.md) - [Commits](https://github.com/parallax/jsPDF/compare/v3.0.4...v4.0.0) --- updated-dependencies: - dependency-name: jspdf dependency-version: 4.0.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- package-lock.json | 119 +++++++++++++++++++++++++++++----------------- package.json | 2 +- 2 files changed, 76 insertions(+), 45 deletions(-) diff --git a/package-lock.json b/package-lock.json index 85740f5..ca83c11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,7 @@ "i18next": "^25.7.2", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", - "jspdf": "^3.0.3", + "jspdf": "^4.0.0", "jspdf-autotable": "^5.0.2", "jszip": "^3.10.1", "lucide": "^0.546.0", @@ -263,7 +263,6 @@ "integrity": "sha512-ZsOJqu4HOG5BlvIFnMU0YKjQ9ZI6r3C31dg2jk5kMWPSdhJpYL9xa5hEe7aieE+707dXeMI4ej3diy6mXdZpgA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@algolia/client-common": "5.46.2", "@algolia/requester-browser-xhr": "5.46.2", @@ -508,7 +507,6 @@ "resolved": "https://registry.npmjs.org/@bentopdf/gs-wasm/-/gs-wasm-0.1.0.tgz", "integrity": "sha512-C71zxZW4R7Oa6fdya5leTh2VOZOxqH8IQlveh13OeuwZ2ulrovSi9629xTzAiIeeVKvDZma1Klxy4MuK65xe9w==", "license": "AGPL-3.0", - "peer": true, "dependencies": { "@types/emscripten": "^1.39.10" } @@ -667,7 +665,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -711,7 +708,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -772,7 +768,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.5.0.tgz", "integrity": "sha512-Yrh9XoVaT8cUgzgqpJ7hx5wg6BqQrCFirqqlSwVb+Ly9oNn4fZbR9GycIWmzJOU5XBnaOJjXfQSaDyoNP0woNA==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/engines": "1.5.0", "@embedpdf/models": "1.5.0" @@ -923,7 +918,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.5.0.tgz", "integrity": "sha512-p7PTNNaIr4gH3jLwX+eLJe1DeUXgi21kVGN6SRx/pocH8esg4jqoOeD/YiRRZoZnPOiy0jBXVhkPkwSmY7a2hQ==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.5.0" }, @@ -940,7 +934,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.5.0.tgz", "integrity": "sha512-ckHgTfvkW6c5Ta7Mc+Dl9C2foVnvEpqEJ84wyBnqrU0OWbe/jsiPhyKBVeartMGqNI/kVfaQTXupyrKhekAVmg==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.5.0" }, @@ -958,7 +951,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.5.0.tgz", "integrity": "sha512-P4YpIZfaW69etYIjphyaL4cGl2pB14h3OdTE0tRQ2pZYZHFLTvlt4q9B3PVSdhlSrHK5nob7jfLGon2U7xCslg==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.5.0" }, @@ -1031,7 +1023,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.5.0.tgz", "integrity": "sha512-ywwSj0ByrlkvrJIHKRzqxARkOZriki8VJUC+T4MV8fGyF4CzvCRJyKlPktahFz+VxhoodqTh7lBCib68dH+GvA==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.5.0" }, @@ -1066,7 +1057,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.5.0.tgz", "integrity": "sha512-RNmTZCZ8X1mA8cw9M7TMDuhO9GtkOalGha2bBL3En3D1IlDRS7PzNNMSMV7eqT7OQICSTltlpJ8p8Qi5esvL/Q==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.5.0" }, @@ -1103,7 +1093,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.5.0.tgz", "integrity": "sha512-zrxLBAZQoPswDuf9q9DrYaQc6B0Ysc2U1hueTjNH/4+ydfl0BFXZkKR63C2e3YmWtXvKjkoIj0GyPzsiBORLUw==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.5.0" }, @@ -1194,7 +1183,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.5.0.tgz", "integrity": "sha512-G8GDyYRhfehw72+r4qKkydnA5+AU8qH67g01Y12b0DzI0VIzymh/05Z4dK8DsY3jyWPXJfw2hlg5+KDHaMBHgQ==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.5.0" }, @@ -2880,6 +2868,7 @@ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", "license": "MIT", + "peer": true, "peerDependencies": { "acorn": "^8.9.0" } @@ -3110,6 +3099,60 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.7.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.7.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", @@ -3521,7 +3564,6 @@ "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", "license": "MIT", - "peer": true, "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" @@ -3676,7 +3718,6 @@ "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", @@ -4035,7 +4076,6 @@ "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "fflate": "^0.8.2", @@ -4350,7 +4390,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4401,7 +4440,6 @@ "integrity": "sha512-qqAXW9QvKf2tTyhpDA4qXv1IfBwD2eduSW6tUEBFIfCeE9gn9HQ9I5+MaKoenRuHrzk5sQoNh1/iof8mY7uD6Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@algolia/abtesting": "1.12.2", "@algolia/client-abtesting": "5.46.2", @@ -4616,6 +4654,7 @@ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">= 0.4" } @@ -5291,6 +5330,7 @@ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -5617,7 +5657,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -6027,7 +6066,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -6263,7 +6301,8 @@ "version": "5.6.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.1.tgz", "integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/devlop": { "version": "1.1.0", @@ -6564,7 +6603,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6669,7 +6707,8 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/espree": { "version": "10.4.0", @@ -6707,6 +6746,7 @@ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.1.tgz", "integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } @@ -6927,7 +6967,6 @@ "integrity": "sha512-Pkp8m55GjxBLnhBoT6OXdMvfRr4TjMAKLvFM566zlIryq5plbhaTmLAJWTGR0EkRwLjEte1lCOG9MxF1ipJrOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tabbable": "^6.4.0" } @@ -7767,6 +7806,7 @@ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.6" } @@ -7969,7 +8009,6 @@ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -8039,11 +8078,10 @@ } }, "node_modules/jspdf": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.4.tgz", - "integrity": "sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.0.0.tgz", + "integrity": "sha512-w12U97Z6edKd2tXDn3LzTLg7C7QLJlx0BPfM3ecjK2BckUl9/81vZ+r5gK4/3KQdhAcEZhENUxRhtgYBj75MqQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "fast-png": "^6.2.0", @@ -8626,7 +8664,8 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/locate-path": { "version": "6.0.0", @@ -8744,6 +8783,7 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -8833,7 +8873,6 @@ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "license": "MIT", - "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -9939,7 +9978,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.1.tgz", "integrity": "sha512-u1/ixq/lVQI0CakKNvLDEcW5zfCjUQfZdK9qqWuIJtsezuyG6pk9TWj75GMuI/EzRSZB/VAE43sNWWZfiy8psw==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -10416,7 +10454,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -10536,6 +10573,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -10788,8 +10826,7 @@ "version": "1.15.6", "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz", "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/source-map": { "version": "0.6.1", @@ -11172,6 +11209,7 @@ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">= 0.4" } @@ -11235,7 +11273,6 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -11427,7 +11464,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11628,7 +11664,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11905,7 +11940,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12070,7 +12104,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12570,7 +12603,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -12631,7 +12663,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -12780,7 +12811,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/compiler-sfc": "3.5.26", @@ -13108,7 +13138,8 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/zip-stream": { "version": "6.0.1", diff --git a/package.json b/package.json index 3b9ae0a..edb68b3 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "i18next": "^25.7.2", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", - "jspdf": "^3.0.3", + "jspdf": "^4.0.0", "jspdf-autotable": "^5.0.2", "jszip": "^3.10.1", "lucide": "^0.546.0", From 1f7238d0b5b4e3b86bf5af89eb9909759886a7d4 Mon Sep 17 00:00:00 2001 From: abdullahalam123 Date: Fri, 9 Jan 2026 20:53:36 +0530 Subject: [PATCH 14/73] feat: add Deskew PDF and Font to Outline tools with improved issue templates New Features: - Add Deskew PDF tool for straightening scanned/skewed PDF pages - Add Font to Outline tool for converting text to vector paths - Add translations for new tools in all supported locales (de, en, id, it, tr, vi, zh) Improvements: - Migrate GitHub issue templates from markdown to YAML forms - Separate templates for bug reports, feature requests, and questions - Add config.yml for issue template chooser - Update sitemap.xml with new tool pages - Update ghostscript loader and helper utilities --- .../ISSUE_TEMPLATE/bug_feature_question.md | 80 -- .github/ISSUE_TEMPLATE/bug_report.yml | 122 +++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature_request.yml | 39 + .github/ISSUE_TEMPLATE/question.yml | 30 + package-lock.json | 8 +- package.json | 2 +- public/locales/de/tools.json | 8 + public/locales/en/tools.json | 8 + public/locales/id/tools.json | 8 + public/locales/it/tools.json | 8 + public/locales/tr/tools.json | 8 + public/locales/vi/tools.json | 8 + public/locales/zh/tools.json | 8 + public/sitemap.xml | 12 + src/js/config/tools.ts | 13 + src/js/logic/compress-pdf-page.ts | 969 ++++++++++-------- src/js/logic/deskew-pdf-page.ts | 255 +++++ src/js/logic/email-to-pdf.ts | 105 +- src/js/logic/font-to-outline-page.ts | 222 ++++ src/js/main.ts | 2 + src/js/utils/ghostscript-loader.ts | 205 +++- src/js/utils/helpers.ts | 154 +++ src/pages/deskew-pdf.html | 677 ++++++++++++ src/pages/font-to-outline.html | 667 ++++++++++++ vite.config.ts | 5 + 26 files changed, 2990 insertions(+), 641 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_feature_question.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/question.yml create mode 100644 src/js/logic/deskew-pdf-page.ts create mode 100644 src/js/logic/font-to-outline-page.ts create mode 100644 src/pages/deskew-pdf.html create mode 100644 src/pages/font-to-outline.html diff --git a/.github/ISSUE_TEMPLATE/bug_feature_question.md b/.github/ISSUE_TEMPLATE/bug_feature_question.md deleted file mode 100644 index 23d24e3..0000000 --- a/.github/ISSUE_TEMPLATE/bug_feature_question.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -name: '🐛 Bug / 💡 Feature / ❓ Question' -about: 'Report a bug, request a feature, or ask a question about BentoPDF' -title: '(Bug) , (Feature) , or (Question) ' -labels: ['needs triage'] -assignees: [] ---- - -## Type of Issue - -Please check one: - -- [ ] 🐛 Bug Report -- [ ] 💡 Feature Request -- [ ] ❓ Question / Help - ---- - -## Description - -Provide a clear and concise description of the issue, feature request, or question. - ---- - -## Steps to Reproduce (for Bugs) - -1. Go to '...' -2. Run '...' -3. Observe error: '...' - -**Expected Behavior:** -Describe what you expected BentoPDF to do. - -**Actual Behavior:** -Describe what actually happened, including error messages. - ---- - -## Feature Request Details (if applicable) - -- What functionality are you requesting? -- Why is this useful? -- Any example or context to illustrate it? - ---- - -## Question Details (if applicable) - -- What is your question? -- What have you tried so far? -- Any relevant code snippet or scenario? - ---- - -## Screenshots / Logs (if applicable) - -Attach any screenshots, logs, or stack traces that help explain the problem or question. - ---- - -## Environment - -- **OS:** (e.g., macOS 14.0 / Ubuntu 22.04 / Windows 11) -- **Dependencies / setup details (if any):** - ---- - -## 💭 Additional Context - -Any other information, suggestions, or references that might help maintainers. - ---- - -✅ **Title Format Reminder:** - -- `(Bug) Text alignment incorrect on multi-line paragraphs` -- `(Feature) Add support for custom PDF metadata` -- `(Question) How to embed custom fonts?` - ---- diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..b638cfd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,122 @@ +name: 🐛 Bug Report +description: Report a bug in BentoPDF +title: "(Bug) " +labels: ["bug", "needs triage"] +body: + - type: markdown + attributes: + value: | + ## ⚠️ Important Notice + **Bug reports without logs or a sample file demonstrating the issue will not be investigated.** + Please help us help you by providing the information needed to reproduce and fix the problem. + + - type: textarea + id: description + attributes: + label: Description + description: Provide a clear and concise description of the bug. + placeholder: What happened? What did you expect to happen? + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: How can we reproduce this issue? + placeholder: | + 1. Go to '...' + 2. Click on '...' + 3. Upload file '...' + 4. See error + validations: + required: true + + - type: textarea + id: console-logs + attributes: + label: Console Logs + description: Open browser DevTools (F12 → Console tab) and paste any errors here. + placeholder: Paste console logs here... + render: shell + validations: + required: true + + - type: textarea + id: sample-file + attributes: + label: Sample PDF or File + description: | + Attach a sample PDF that reproduces the issue, or describe how to create one. + If you cannot share the original, create a minimal example that shows the problem. + placeholder: Drag and drop your file here, or describe how to reproduce with any PDF... + validations: + required: true + + - type: dropdown + id: browser + attributes: + label: Browser + description: Which browser are you using? + options: + - Chrome + - Firefox + - Safari + - Edge + - Brave + - Other + validations: + required: true + + - type: input + id: browser-version + attributes: + label: Browser Version + description: e.g., Chrome 120, Firefox 121 + placeholder: "120" + validations: + required: true + + - type: dropdown + id: os + attributes: + label: Operating System + options: + - macOS + - Windows + - Linux + - iOS + - Android + - Other + validations: + required: true + + - type: input + id: bentopdf-version + attributes: + label: BentoPDF Version + description: Check the footer or package.json + placeholder: "1.15.4" + validations: + required: false + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Any other information that might help us debug this issue. + placeholder: Screenshots, network errors, stack traces, etc. + validations: + required: false + + - type: checkboxes + id: checklist + attributes: + label: Pre-submission Checklist + options: + - label: I have included console logs from the browser DevTools + required: true + - label: I have attached a sample file or described how to reproduce the issue + required: true + - label: I have searched existing issues to ensure this is not a duplicate + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3c041ab --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: 💬 Discord Community + url: https://discord.gg/Bgq3Ay3f2w + about: Join our Discord for quick questions and community support + - name: 📖 Documentation + url: https://github.com/nicholaschen09/BentoPDF#readme + about: Check the README for setup and usage instructions diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..9797c6a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,39 @@ +name: 💡 Feature Request +description: Suggest a new feature for BentoPDF +title: "(Feature) " +labels: ["enhancement", "needs triage"] +body: + - type: textarea + id: description + attributes: + label: Feature Description + description: What functionality are you requesting? + placeholder: Describe the feature you'd like to see... + validations: + required: true + + - type: textarea + id: use-case + attributes: + label: Use Case + description: Why is this feature useful? What problem does it solve? + placeholder: Explain why you need this feature... + validations: + required: true + + - type: textarea + id: examples + attributes: + label: Examples + description: Any examples, mockups, or references to illustrate the feature? + placeholder: Links to similar features, screenshots, etc. + validations: + required: false + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Any other information about the feature request. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..ac5e905 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,30 @@ +name: ❓ Question +description: Ask a question about BentoPDF +title: "(Question) " +labels: ["question"] +body: + - type: textarea + id: question + attributes: + label: Question + description: What would you like to know? + placeholder: Your question here... + validations: + required: true + + - type: textarea + id: tried + attributes: + label: What have you tried? + description: What solutions have you already attempted? + placeholder: Describe what you've tried so far... + validations: + required: false + + - type: textarea + id: context + attributes: + label: Additional Context + description: Any relevant code snippets, screenshots, or scenarios. + validations: + required: false diff --git a/package-lock.json b/package-lock.json index 849da8c..df8e4b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "AGPL-3.0-only", "dependencies": { "@bentopdf/gs-wasm": "^0.1.0", - "@bentopdf/pymupdf-wasm": "^0.1.11", + "@bentopdf/pymupdf-wasm": "^0.11.12", "@fontsource/cedarville-cursive": "^5.2.7", "@fontsource/dancing-script": "^5.2.8", "@fontsource/dm-sans": "^5.2.8", @@ -516,9 +516,9 @@ } }, "node_modules/@bentopdf/pymupdf-wasm": { - "version": "0.1.11", - "resolved": "https://registry.npmjs.org/@bentopdf/pymupdf-wasm/-/pymupdf-wasm-0.1.11.tgz", - "integrity": "sha512-sbDFmvm2KzT3oCmqNqMx7w6TMsKpLXeooVK8EVRjyQIV4hU5Ioq0JxWMr8SX7MESu8Caz1feeELd6zt5K966SA==", + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/@bentopdf/pymupdf-wasm/-/pymupdf-wasm-0.11.12.tgz", + "integrity": "sha512-AcSg7v7pVhYcH23qLDEj3yTABlGIkZULPmrvWHRtEyD5QMS0TWOLUq/c0ATO371PKVlI4jEUpCBUj+iBsFJwVQ==", "license": "AGPL-3.0", "peerDependencies": { "@bentopdf/gs-wasm": "*" diff --git a/package.json b/package.json index a4d40e7..a29fcbe 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ }, "dependencies": { "@bentopdf/gs-wasm": "^0.1.0", - "@bentopdf/pymupdf-wasm": "^0.1.11", + "@bentopdf/pymupdf-wasm": "^0.11.12", "@fontsource/cedarville-cursive": "^5.2.7", "@fontsource/dancing-script": "^5.2.8", "@fontsource/dm-sans": "^5.2.8", diff --git a/public/locales/de/tools.json b/public/locales/de/tools.json index d91af6e..769e591 100644 --- a/public/locales/de/tools.json +++ b/public/locales/de/tools.json @@ -521,5 +521,13 @@ "subtitle": "E-Mail-Dateien (EML, MSG) in PDF-Format konvertieren. Unterstützt Outlook-Exporte und Standard-E-Mail-Formate.", "acceptedFormats": "EML, MSG-Dateien", "convertButton": "In PDF konvertieren" + }, + "fontToOutline": { + "name": "Schriftart zu Umriss", + "subtitle": "Alle Schriftarten in Vektorumrisse für konsistente Darstellung auf allen Geräten konvertieren." + }, + "deskewPdf": { + "name": "PDF entzerren", + "subtitle": "Automatisch schiefe gescannte Seiten mit OpenCV begradigen." } } diff --git a/public/locales/en/tools.json b/public/locales/en/tools.json index a85839c..e093480 100644 --- a/public/locales/en/tools.json +++ b/public/locales/en/tools.json @@ -521,5 +521,13 @@ "subtitle": "Convert email files (EML, MSG) to PDF format. Supports Outlook exports and standard email formats.", "acceptedFormats": "EML, MSG files", "convertButton": "Convert to PDF" + }, + "fontToOutline": { + "name": "Font to Outline", + "subtitle": "Convert all fonts to vector outlines for consistent rendering across all devices." + }, + "deskewPdf": { + "name": "Deskew PDF", + "subtitle": "Automatically straighten tilted scanned pages using OpenCV." } } diff --git a/public/locales/id/tools.json b/public/locales/id/tools.json index 497ccdc..95e9bb6 100644 --- a/public/locales/id/tools.json +++ b/public/locales/id/tools.json @@ -521,5 +521,13 @@ "subtitle": "Konversi file email (EML, MSG) ke format PDF. Mendukung ekspor Outlook dan format email standar.", "acceptedFormats": "File EML, MSG", "convertButton": "Konversi ke PDF" + }, + "fontToOutline": { + "name": "Font ke Garis Tepi", + "subtitle": "Konversi semua font ke garis tepi vektor untuk tampilan konsisten di semua perangkat." + }, + "deskewPdf": { + "name": "Luruskan PDF", + "subtitle": "Otomatis meluruskan halaman hasil pindai yang miring menggunakan OpenCV." } } diff --git a/public/locales/it/tools.json b/public/locales/it/tools.json index 1a9960b..9992060 100644 --- a/public/locales/it/tools.json +++ b/public/locales/it/tools.json @@ -494,5 +494,13 @@ "subtitle": "Converti file email (EML, MSG) in formato PDF. Supporta esportazioni Outlook e formati email standard.", "acceptedFormats": "File EML, MSG", "convertButton": "Converti in PDF" + }, + "fontToOutline": { + "name": "Font in Contorni", + "subtitle": "Converti tutti i font in contorni vettoriali per una visualizzazione coerente su tutti i dispositivi." + }, + "deskewPdf": { + "name": "Raddrizza PDF", + "subtitle": "Raddrizza automaticamente le pagine scansionate inclinate usando OpenCV." } } diff --git a/public/locales/tr/tools.json b/public/locales/tr/tools.json index 8467d25..0947fd3 100644 --- a/public/locales/tr/tools.json +++ b/public/locales/tr/tools.json @@ -284,5 +284,13 @@ "subtitle": "E-posta dosyalarını (EML, MSG) PDF formatına dönüştürün. Outlook dışa aktarmalarını ve standart e-posta formatlarını destekler.", "acceptedFormats": "EML, MSG Dosyaları", "convertButton": "PDF'ye Dönüştür" + }, + "fontToOutline": { + "name": "Yazı Tipi Çerçeveye Dönüştür", + "subtitle": "Tüm yazı tiplerini vektör çerçevelere dönüştürün, tüm cihazlarda tutarlı görüntü için." + }, + "deskewPdf": { + "name": "PDF Eğriliğini Düzelt", + "subtitle": "OpenCV kullanarak eğik taranmış sayfaları otomatik olarak düzeltin." } } diff --git a/public/locales/vi/tools.json b/public/locales/vi/tools.json index 21df991..04b37af 100644 --- a/public/locales/vi/tools.json +++ b/public/locales/vi/tools.json @@ -521,5 +521,13 @@ "subtitle": "Chuyển đổi tệp email (EML, MSG) sang định dạng PDF. Hỗ trợ xuất Outlook và định dạng email tiêu chuẩn.", "acceptedFormats": "Tệp EML, MSG", "convertButton": "Chuyển đổi sang PDF" + }, + "fontToOutline": { + "name": "Phông chữ thành đường viền", + "subtitle": "Chuyển đổi tất cả phông chữ thành đường viền vector để hiển thị nhất quán trên mọi thiết bị." + }, + "deskewPdf": { + "name": "Chỉnh nghiêng PDF", + "subtitle": "Tự động làm thẳng các trang quét bị nghiêng bằng OpenCV." } } diff --git a/public/locales/zh/tools.json b/public/locales/zh/tools.json index fca82d6..a93c822 100644 --- a/public/locales/zh/tools.json +++ b/public/locales/zh/tools.json @@ -518,5 +518,13 @@ "subtitle": "将电子邮件文件 (EML, MSG) 转换为 PDF 格式。支持 Outlook 导出和标准邮件格式。", "acceptedFormats": "EML, MSG 文件", "convertButton": "转换为 PDF" + }, + "fontToOutline": { + "name": "字体转轮廓", + "subtitle": "将所有字体转换为矢量轮廓,确保在所有设备上一致呈现。" + }, + "deskewPdf": { + "name": "校正 PDF", + "subtitle": "使用 OpenCV 自动校正倾斜的扫描页面。" } } diff --git a/public/sitemap.xml b/public/sitemap.xml index 7613a80..12fa5dc 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -699,6 +699,18 @@ monthly 0.5 + + https://www.bentopdf.com/deskew-pdf + 2026-01-08 + monthly + 0.5 + + + https://www.bentopdf.com/font-to-outline + 2026-01-08 + monthly + 0.5 + diff --git a/src/js/config/tools.ts b/src/js/config/tools.ts index 9720e18..4299090 100644 --- a/src/js/config/tools.ts +++ b/src/js/config/tools.ts @@ -693,6 +693,19 @@ export const categories = [ subtitle: 'Convert PDF to image-based PDF. Flatten layers and remove selectable text.', }, + { + href: import.meta.env.BASE_URL + 'deskew-pdf.html', + name: 'Deskew PDF', + icon: 'ph-perspective', + subtitle: 'Automatically straighten tilted scanned pages using OpenCV.', + }, + { + href: import.meta.env.BASE_URL + 'font-to-outline.html', + name: 'Font to Outline', + icon: 'ph-text-outdent', + subtitle: + 'Convert all fonts to vector outlines for consistent rendering.', + }, ], }, { diff --git a/src/js/logic/compress-pdf-page.ts b/src/js/logic/compress-pdf-page.ts index 8aea672..6777f36 100644 --- a/src/js/logic/compress-pdf-page.ts +++ b/src/js/logic/compress-pdf-page.ts @@ -1,9 +1,9 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; import { - downloadFile, - readFileAsArrayBuffer, - formatBytes, - getPDFDocument, + downloadFile, + readFileAsArrayBuffer, + formatBytes, + getPDFDocument, } from '../utils/helpers.js'; import { state } from '../state.js'; import { createIcons, icons } from 'lucide'; @@ -12,485 +12,580 @@ import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; import { getWasmBaseUrl } from '../config/wasm-cdn-config.js'; import * as pdfjsLib from 'pdfjs-dist'; -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(); 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, - }, + 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 }, + 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; - } + fileBlob: Blob, + level: string, + customSettings?: { + imageQuality?: number; + dpiTarget?: number; + dpiThreshold?: number; + removeMetadata?: boolean; + subsetFonts?: boolean; + convertToGrayscale?: boolean; + removeThumbnails?: boolean; + } ) { - const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); - await pymupdf.load(); + const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf')); + await pymupdf.load(); - const preset = CONDENSE_PRESETS[level as keyof typeof CONDENSE_PRESETS] || CONDENSE_PRESETS.balanced; + const preset = + CONDENSE_PRESETS[level as keyof typeof CONDENSE_PRESETS] || + CONDENSE_PRESETS.balanced; - const dpiTarget = customSettings?.dpiTarget ?? preset.images.dpiTarget; - const userThreshold = customSettings?.dpiThreshold ?? preset.images.dpiThreshold; - const dpiThreshold = Math.max(userThreshold, dpiTarget + 10); + const dpiTarget = customSettings?.dpiTarget ?? preset.images.dpiTarget; + const userThreshold = + customSettings?.dpiThreshold ?? preset.images.dpiThreshold; + const dpiThreshold = Math.max(userThreshold, dpiTarget + 10); - const options = { + 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, + }, + }; + + 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: { - enabled: true, - quality: customSettings?.imageQuality ?? preset.images.quality, - dpiTarget, - dpiThreshold, - convertToGray: customSettings?.convertToGrayscale ?? false, + ...options.images, + enabled: 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, - }, - }; + }; - 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}`); + const result = await pymupdf.compressPdf(fileBlob, fallbackOptions); + return { ...result, usedFallback: true }; } + + throw new Error(`PDF compression failed: ${errorMessage}`); + } } -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; +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); - const viewport = page.getViewport({ scale: settings.scale }); - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - canvas.height = viewport.height; - canvas.width = viewport.width; + for (let i = 1; i <= pdfJsDoc.numPages; i++) { + const page = await pdfJsDoc.getPage(i); + const viewport = page.getViewport({ scale: settings.scale }); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + 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((blob) => resolve(blob as Blob), 'image/jpeg', settings.quality) - ); - const jpegBytes = await jpegBlob.arrayBuffer(); - const jpegImage = await newPdfDoc.embedJpg(jpegBytes); - const newPage = newPdfDoc.addPage([viewport.width, viewport.height]); - newPage.drawImage(jpegImage, { - x: 0, - y: 0, - width: viewport.width, - height: viewport.height, - }); - } - return await newPdfDoc.save(); + const jpegBlob = await new Promise((resolve) => + canvas.toBlob( + (blob) => resolve(blob as Blob), + 'image/jpeg', + settings.quality + ) + ); + const jpegBytes = await jpegBlob.arrayBuffer(); + const jpegImage = await newPdfDoc.embedJpg(jpegBytes); + const newPage = newPdfDoc.addPage([viewport.width, viewport.height]); + newPage.drawImage(jpegImage, { + x: 0, + y: 0, + width: viewport.width, + height: viewport.height, + }); + } + return await newPdfDoc.save(); } document.addEventListener('DOMContentLoaded', () => { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const compressOptions = document.getElementById('compress-options'); - 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'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const compressOptions = document.getElementById('compress-options'); + 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; + let useCustomSettings = false; - if (backBtn) { - backBtn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL; - }); - } + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } - // 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 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; - } - }); - } + // 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 (!compressOptions) return; + const updateUI = async () => { + if (!compressOptions) return; - if (state.files.length > 0) { - const fileDisplayArea = document.getElementById('file-display-area'); - if (fileDisplayArea) { - fileDisplayArea.innerHTML = ''; + if (state.files.length > 0) { + 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); + 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 = ''; - removeBtn.onclick = () => { - state.files = state.files.filter((_, i) => i !== index); - updateUI(); - }; - - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - } - - createIcons({ icons }); - } - compressOptions.classList.remove('hidden'); - } else { - compressOptions.classList.add('hidden'); - // Clear file display area - const fileDisplayArea = document.getElementById('file-display-area'); - if (fileDisplayArea) fileDisplayArea.innerHTML = ''; - } - }; - - const resetState = () => { - state.files = []; - state.pdfDoc = null; - - const compressionLevel = document.getElementById('compression-level') as HTMLSelectElement; - if (compressionLevel) compressionLevel.value = 'balanced'; - - 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(); - }; - - 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; - - let customSettings: { - imageQuality?: number; - dpiTarget?: number; - dpiThreshold?: number; - removeMetadata?: boolean; - subsetFonts?: boolean; - convertToGrayscale?: boolean; - removeThumbnails?: boolean; - } | undefined; - - 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) { - showAlert('No Files', 'Please select at least one PDF file.'); - hideLoader(); - return; - } - - if (state.files.length === 1) { - const originalFile = state.files[0]; - - let resultBlob: Blob; - let resultSize: number; - let usedMethod: string; - - if (algorithm === 'condense') { - showLoader('Loading engine...'); - const result = await performCondenseCompression(originalFile, level, customSettings); - resultBlob = result.blob; - resultSize = result.compressedSize; - usedMethod = 'Condense'; - - // 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(resultSize); - const savings = originalFile.size - resultSize; - const savingsPercent = savings > 0 ? ((savings / originalFile.size) * 100).toFixed(1) : 0; - - downloadFile( - resultBlob, - originalFile.name.replace(/\.pdf$/i, '') + '_compressed.pdf' - ); - - hideLoader(); - - if (savings > 0) { - showAlert( - 'Compression Complete', - `Method: ${usedMethod}. File size reduced from ${originalSize} to ${compressedSize} (Saved ${savingsPercent}%).`, - 'success', - () => resetState() - ); - } else { - showAlert( - 'Compression Finished', - `Method: ${usedMethod}. Could not reduce file size further. Original: ${originalSize}, New: ${compressedSize}.`, - 'warning', - () => resetState() - ); - } - } else { - showLoader('Compressing multiple PDFs...'); - const JSZip = (await import('jszip')).default; - const zip = new JSZip(); - let totalOriginalSize = 0; - let totalCompressedSize = 0; - - for (let i = 0; i < state.files.length; i++) { - const file = state.files[i]; - showLoader(`Compressing ${i + 1}/${state.files.length}: ${file.name}...`); - totalOriginalSize += file.size; - - let resultBytes: Uint8Array; - if (algorithm === 'condense') { - const result = await performCondenseCompression(file, level, customSettings); - resultBytes = new Uint8Array(await result.blob.arrayBuffer()); - } else { - const arrayBuffer = await readFileAsArrayBuffer(file) as ArrayBuffer; - resultBytes = await performPhotonCompression(arrayBuffer, level); - } - - totalCompressedSize += resultBytes.length; - const baseName = file.name.replace(/\.pdf$/i, ''); - zip.file(`${baseName}_compressed.pdf`, resultBytes); - } - - const zipBlob = await zip.generateAsync({ type: 'blob' }); - const totalSavings = totalOriginalSize - totalCompressedSize; - const totalSavingsPercent = totalSavings > 0 - ? ((totalSavings / totalOriginalSize) * 100).toFixed(1) - : 0; - - downloadFile(zipBlob, 'compressed-pdfs.zip'); - - hideLoader(); - - if (totalSavings > 0) { - showAlert( - 'Compression Complete', - `Compressed ${state.files.length} PDF(s). Total size reduced from ${formatBytes(totalOriginalSize)} to ${formatBytes(totalCompressedSize)} (Saved ${totalSavingsPercent}%).`, - 'success', - () => resetState() - ); - } else { - showAlert( - 'Compression Finished', - `Compressed ${state.files.length} PDF(s). Total size: ${formatBytes(totalCompressedSize)}.`, - 'info', - () => resetState() - ); - } - } - } catch (e: any) { - hideLoader(); - console.error('[CompressPDF] Error:', e); - showAlert( - 'Error', - `An error occurred during compression. Error: ${e.message}` - ); - } - }; - - const handleFileSelect = (files: FileList | null) => { - if (files && files.length > 0) { - state.files = [...state.files, ...Array.from(files)]; + const removeBtn = document.createElement('button'); + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + state.files = state.files.filter((_, i) => i !== index); updateUI(); + }; + + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); } - }; - if (fileInput && dropZone) { - fileInput.addEventListener('change', (e) => { - handleFileSelect((e.target as HTMLInputElement).files); - }); + createIcons({ icons }); + } + compressOptions.classList.remove('hidden'); + } else { + compressOptions.classList.add('hidden'); + // Clear file display area + const fileDisplayArea = document.getElementById('file-display-area'); + if (fileDisplayArea) fileDisplayArea.innerHTML = ''; + } + }; - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('bg-gray-700'); - }); + const resetState = () => { + state.files = []; + state.pdfDoc = null; - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('bg-gray-700'); - }); + const compressionLevel = document.getElementById( + 'compression-level' + ) as HTMLSelectElement; + if (compressionLevel) compressionLevel.value = 'balanced'; - 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'); - if (pdfFiles.length > 0) { - const dataTransfer = new DataTransfer(); - pdfFiles.forEach(f => dataTransfer.items.add(f)); - handleFileSelect(dataTransfer.files); - } - } - }); + if (algorithmSelect) algorithmSelect.value = 'condense'; - fileInput.addEventListener('click', () => { - fileInput.value = ''; - }); + 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(); + }; + + 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; + + let customSettings: + | { + imageQuality?: number; + dpiTarget?: number; + dpiThreshold?: number; + removeMetadata?: boolean; + subsetFonts?: boolean; + convertToGrayscale?: boolean; + removeThumbnails?: boolean; + } + | undefined; + + 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; } - if (addMoreBtn) { - addMoreBtn.addEventListener('click', () => { - fileInput.click(); - }); - } + try { + if (state.files.length === 0) { + showAlert('No Files', 'Please select at least one PDF file.'); + hideLoader(); + return; + } - if (clearFilesBtn) { - clearFilesBtn.addEventListener('click', () => { - resetState(); - }); - } + if (state.files.length === 1) { + const originalFile = state.files[0]; - if (processBtn) { - processBtn.addEventListener('click', compress); + let resultBlob: Blob; + let resultSize: number; + let usedMethod: string; + + if (algorithm === 'condense') { + showLoader('Running Condense compression...'); + const result = await performCondenseCompression( + originalFile, + level, + customSettings + ); + resultBlob = result.blob; + resultSize = result.compressedSize; + usedMethod = 'Condense'; + + // 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(resultSize); + const savings = originalFile.size - resultSize; + const savingsPercent = + savings > 0 ? ((savings / originalFile.size) * 100).toFixed(1) : 0; + + downloadFile( + resultBlob, + originalFile.name.replace(/\.pdf$/i, '') + '_compressed.pdf' + ); + + hideLoader(); + + if (savings > 0) { + showAlert( + 'Compression Complete', + `Method: ${usedMethod}. File size reduced from ${originalSize} to ${compressedSize} (Saved ${savingsPercent}%).`, + 'success', + () => resetState() + ); + } else { + showAlert( + 'Compression Finished', + `Method: ${usedMethod}. Could not reduce file size further. Original: ${originalSize}, New: ${compressedSize}.`, + 'warning', + () => resetState() + ); + } + } else { + showLoader('Compressing multiple PDFs...'); + const JSZip = (await import('jszip')).default; + const zip = new JSZip(); + let totalOriginalSize = 0; + let totalCompressedSize = 0; + + for (let i = 0; i < state.files.length; i++) { + const file = state.files[i]; + showLoader( + `Compressing ${i + 1}/${state.files.length}: ${file.name}...` + ); + totalOriginalSize += file.size; + + let resultBytes: Uint8Array; + if (algorithm === 'condense') { + const result = await performCondenseCompression( + file, + level, + customSettings + ); + resultBytes = new Uint8Array(await result.blob.arrayBuffer()); + } else { + const arrayBuffer = (await readFileAsArrayBuffer( + file + )) as ArrayBuffer; + resultBytes = await performPhotonCompression(arrayBuffer, level); + } + + totalCompressedSize += resultBytes.length; + const baseName = file.name.replace(/\.pdf$/i, ''); + zip.file(`${baseName}_compressed.pdf`, resultBytes); + } + + const zipBlob = await zip.generateAsync({ type: 'blob' }); + const totalSavings = totalOriginalSize - totalCompressedSize; + const totalSavingsPercent = + totalSavings > 0 + ? ((totalSavings / totalOriginalSize) * 100).toFixed(1) + : 0; + + downloadFile(zipBlob, 'compressed-pdfs.zip'); + + hideLoader(); + + if (totalSavings > 0) { + showAlert( + 'Compression Complete', + `Compressed ${state.files.length} PDF(s). Total size reduced from ${formatBytes(totalOriginalSize)} to ${formatBytes(totalCompressedSize)} (Saved ${totalSavingsPercent}%).`, + 'success', + () => resetState() + ); + } else { + showAlert( + 'Compression Finished', + `Compressed ${state.files.length} PDF(s). Total size: ${formatBytes(totalCompressedSize)}.`, + 'info', + () => resetState() + ); + } + } + } catch (e: any) { + hideLoader(); + console.error('[CompressPDF] Error:', e); + showAlert( + 'Error', + `An error occurred during compression. 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' + ); + if (pdfFiles.length > 0) { + const dataTransfer = new DataTransfer(); + pdfFiles.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', compress); + } }); diff --git a/src/js/logic/deskew-pdf-page.ts b/src/js/logic/deskew-pdf-page.ts new file mode 100644 index 0000000..e86a67e --- /dev/null +++ b/src/js/logic/deskew-pdf-page.ts @@ -0,0 +1,255 @@ +import { PyMuPDF } from '@bentopdf/pymupdf-wasm'; +import { createIcons, icons } from 'lucide'; +import { downloadFile } from '../utils/helpers'; + +interface DeskewResult { + totalPages: number; + correctedPages: number; + angles: number[]; + corrected: boolean[]; +} + +let selectedFiles: File[] = []; +let pymupdf: PyMuPDF | null = null; + +function initPyMuPDF(): PyMuPDF { + if (!pymupdf) { + pymupdf = new PyMuPDF({ + assetPath: import.meta.env.BASE_URL + 'pymupdf-wasm/', + }); + } + return pymupdf; +} + +function showLoader(message: string): void { + const loader = document.getElementById('loader-modal'); + const text = document.getElementById('loader-text'); + if (loader && text) { + text.textContent = message; + loader.classList.remove('hidden'); + } +} + +function hideLoader(): void { + const loader = document.getElementById('loader-modal'); + if (loader) { + loader.classList.add('hidden'); + } +} + +function showAlert(title: string, message: string): void { + const modal = document.getElementById('alert-modal'); + const titleEl = document.getElementById('alert-title'); + const msgEl = document.getElementById('alert-message'); + if (modal && titleEl && msgEl) { + titleEl.textContent = title; + msgEl.textContent = message; + modal.classList.remove('hidden'); + } +} + +function updateFileDisplay(): void { + const fileDisplayArea = document.getElementById('file-display-area'); + const fileControls = document.getElementById('file-controls'); + const deskewOptions = document.getElementById('deskew-options'); + const resultsArea = document.getElementById('results-area'); + + if (!fileDisplayArea || !fileControls || !deskewOptions || !resultsArea) + return; + + resultsArea.classList.add('hidden'); + + if (selectedFiles.length === 0) { + fileDisplayArea.innerHTML = ''; + fileControls.classList.add('hidden'); + deskewOptions.classList.add('hidden'); + return; + } + + fileControls.classList.remove('hidden'); + deskewOptions.classList.remove('hidden'); + + fileDisplayArea.innerHTML = selectedFiles + .map( + (file, index) => ` +
+
+ + ${file.name} + (${(file.size / 1024).toFixed(1)} KB) +
+ +
+ ` + ) + .join(''); + + createIcons({ icons }); + + fileDisplayArea.querySelectorAll('.remove-file').forEach((btn) => { + btn.addEventListener('click', (e) => { + const index = parseInt( + (e.currentTarget as HTMLElement).dataset.index || '0', + 10 + ); + selectedFiles.splice(index, 1); + updateFileDisplay(); + }); + }); +} + +function displayResults(result: DeskewResult): void { + const resultsArea = document.getElementById('results-area'); + const totalEl = document.getElementById('result-total'); + const correctedEl = document.getElementById('result-corrected'); + const anglesList = document.getElementById('angles-list'); + + if (!resultsArea || !totalEl || !correctedEl || !anglesList) return; + + resultsArea.classList.remove('hidden'); + totalEl.textContent = result.totalPages.toString(); + correctedEl.textContent = result.correctedPages.toString(); + + anglesList.innerHTML = result.angles + .map((angle, idx) => { + const wasCorrected = result.corrected[idx]; + const color = wasCorrected ? 'text-green-400' : 'text-gray-400'; + const icon = wasCorrected ? 'check' : 'minus'; + return ` +
+ + Page ${idx + 1}: + ${angle.toFixed(2)}° + ${wasCorrected ? '(corrected)' : ''} +
+ `; + }) + .join(''); + + createIcons({ icons }); +} + +async function processDeskew(): Promise { + if (selectedFiles.length === 0) { + showAlert('No Files', 'Please select at least one PDF file.'); + return; + } + + const thresholdSelect = document.getElementById( + 'deskew-threshold' + ) as HTMLSelectElement; + const dpiSelect = document.getElementById('deskew-dpi') as HTMLSelectElement; + + const threshold = parseFloat(thresholdSelect?.value || '0.5'); + const dpi = parseInt(dpiSelect?.value || '150', 10); + + showLoader('Initializing PyMuPDF...'); + + try { + const pdf = initPyMuPDF(); + await pdf.load(); + + for (const file of selectedFiles) { + showLoader(`Deskewing ${file.name}...`); + + const { pdf: resultPdf, result } = await pdf.deskewPdf(file, { + threshold, + dpi, + }); + + displayResults(result); + + const filename = file.name.replace('.pdf', '_deskewed.pdf'); + downloadFile(resultPdf, filename); + } + + hideLoader(); + showAlert( + 'Success', + `Deskewed ${selectedFiles.length} file(s). ${selectedFiles.length > 1 ? 'Downloads started for all files.' : ''}` + ); + } catch (error) { + hideLoader(); + console.error('Deskew error:', error); + showAlert( + 'Error', + `Failed to deskew PDF: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +function initPage(): void { + 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'); + const alertOk = document.getElementById('alert-ok'); + const backBtn = document.getElementById('back-to-tools'); + + if (fileInput) { + fileInput.addEventListener('change', () => { + if (fileInput.files) { + selectedFiles = [...selectedFiles, ...Array.from(fileInput.files)]; + updateFileDisplay(); + 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'); + if (e.dataTransfer?.files) { + const pdfFiles = Array.from(e.dataTransfer.files).filter( + (f) => f.type === 'application/pdf' + ); + selectedFiles = [...selectedFiles, ...pdfFiles]; + updateFileDisplay(); + } + }); + } + + if (addMoreBtn) { + addMoreBtn.addEventListener('click', () => fileInput?.click()); + } + + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', () => { + selectedFiles = []; + updateFileDisplay(); + }); + } + + if (processBtn) { + processBtn.addEventListener('click', processDeskew); + } + + if (alertOk) { + alertOk.addEventListener('click', () => { + document.getElementById('alert-modal')?.classList.add('hidden'); + }); + } + + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = '/'; + }); + } + + createIcons({ icons }); +} + +document.addEventListener('DOMContentLoaded', initPage); diff --git a/src/js/logic/email-to-pdf.ts b/src/js/logic/email-to-pdf.ts index 378f1d9..40772a3 100644 --- a/src/js/logic/email-to-pdf.ts +++ b/src/js/logic/email-to-pdf.ts @@ -1,14 +1,16 @@ import PostalMime from 'postal-mime'; import MsgReader from '@kenjiuno/msgreader'; -import { formatBytes, escapeHtml } from '../utils/helpers.js'; +import { + formatBytes, + escapeHtml, + uint8ArrayToBase64, + sanitizeEmailHtml, + formatRawDate, +} from '../utils/helpers.js'; import type { EmailAttachment, ParsedEmail, EmailRenderOptions } from '@/types'; -// Re-export types for convenience export type { EmailAttachment, ParsedEmail, EmailRenderOptions }; -/** - * Format email address without angle brackets for cleaner display - */ function formatAddress( name: string | undefined, email: string | undefined @@ -172,80 +174,6 @@ export async function parseMsgFile(file: File): Promise { }; } -/** - * Formats a raw RFC 2822 date string into a nicer human-readable format, - * while preserving the original timezone and time. - * Example input: "Sun, 8 Jan 2017 20:37:44 +0200" - * Example output: "Sunday, January 8, 2017 at 8:37 PM (+0200)" - */ -function formatRawDate(raw: string): string { - try { - // Regex to parse RFC 2822 date parts: Day, DD Mon YYYY HH:MM:SS Timezone - const match = raw.match( - /([A-Za-z]{3}),\s+(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2})(?::(\d{2}))?\s+([+-]\d{4})/ - ); - - if (match) { - const [ - , - dayAbbr, - dom, - monthAbbr, - year, - hoursStr, - minsStr, - secsStr, - timezone, - ] = match; - - // Map abbreviations to full names - const days: Record = { - Sun: 'Sunday', - Mon: 'Monday', - Tue: 'Tuesday', - Wed: 'Wednesday', - Thu: 'Thursday', - Fri: 'Friday', - Sat: 'Saturday', - }; - const months: Record = { - Jan: 'January', - Feb: 'February', - Mar: 'March', - Apr: 'April', - May: 'May', - Jun: 'June', - Jul: 'July', - Aug: 'August', - Sep: 'September', - Oct: 'October', - Nov: 'November', - Dec: 'December', - }; - - const fullDay = days[dayAbbr] || dayAbbr; - const fullMonth = months[monthAbbr] || monthAbbr; - - // Convert to 12-hour format manually - let hours = parseInt(hoursStr, 10); - const ampm = hours >= 12 ? 'PM' : 'AM'; - hours = hours % 12; - hours = hours ? hours : 12; // the hour '0' should be '12' - - // Format timezone: +0200 -> UTC+02:00 - const tzSign = timezone.substring(0, 1); - const tzHours = timezone.substring(1, 3); - const tzMins = timezone.substring(3, 5); - const formattedTz = `UTC${tzSign}${tzHours}:${tzMins}`; - - return `${fullDay}, ${fullMonth} ${dom}, ${year} at ${hours}:${minsStr} ${ampm} (${formattedTz})`; - } - } catch (e) { - // Fallback to raw string if parsing fails - } - return raw; -} - /** * Replace CID references in HTML with base64 data URIs */ @@ -263,23 +191,13 @@ function processInlineImages( } }); - // Replace src="cid:..." return html.replace(/src=["']cid:([^"']+)["']/g, (match, cid) => { const att = cidMap.get(cid); if (att && att.content) { - // Convert Uint8Array to base64 - let binary = ''; - const len = att.content.byteLength; - for (let i = 0; i < len; i++) { - binary += String.fromCharCode(att.content[i]); - } - const base64 = - typeof btoa === 'function' - ? btoa(binary) - : Buffer.from(binary, 'binary').toString('base64'); + const base64 = uint8ArrayToBase64(att.content); return `src="data:${att.contentType};base64,${base64}"`; } - return match; // Keep original if not found + return match; }); } @@ -291,12 +209,12 @@ export function renderEmailToHtml( let processedHtml = ''; if (email.htmlBody) { - processedHtml = processInlineImages(email.htmlBody, email.attachments); + const sanitizedHtml = sanitizeEmailHtml(email.htmlBody); + processedHtml = processInlineImages(sanitizedHtml, email.attachments); } else { processedHtml = `
${escapeHtml(email.textBody)}
`; } - // Format date in a human-readable way let dateStr = 'Unknown Date'; if (email.rawDateString) { dateStr = formatRawDate(email.rawDateString); @@ -329,7 +247,6 @@ export function renderEmailToHtml( ` : ''; - // Build CC/BCC rows let ccBccHtml = ''; if (includeCcBcc) { if (email.cc.length > 0) { diff --git a/src/js/logic/font-to-outline-page.ts b/src/js/logic/font-to-outline-page.ts new file mode 100644 index 0000000..83bbb29 --- /dev/null +++ b/src/js/logic/font-to-outline-page.ts @@ -0,0 +1,222 @@ +import { showAlert } from '../ui.js'; +import { downloadFile, formatBytes } from '../utils/helpers.js'; +import { convertFileToOutlines } from '../utils/ghostscript-loader.js'; +import { icons, createIcons } from 'lucide'; +import JSZip from 'jszip'; + +interface FontToOutlineState { + files: File[]; +} + +const pageState: FontToOutlineState = { + files: [], +}; + +function resetState() { + pageState.files = []; + + const fileDisplayArea = document.getElementById('file-display-area'); + if (fileDisplayArea) fileDisplayArea.innerHTML = ''; + + const toolOptions = document.getElementById('tool-options'); + if (toolOptions) toolOptions.classList.add('hidden'); + + const fileControls = document.getElementById('file-controls'); + if (fileControls) fileControls.classList.add('hidden'); + + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; +} + +async function updateUI() { + const fileDisplayArea = document.getElementById('file-display-area'); + const toolOptions = document.getElementById('tool-options'); + const fileControls = document.getElementById('file-controls'); + + if (!fileDisplayArea) return; + + fileDisplayArea.innerHTML = ''; + + if (pageState.files.length > 0) { + pageState.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 = ''; + removeBtn.onclick = function () { + pageState.files.splice(index, 1); + updateUI(); + }; + + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + }); + + createIcons({ icons }); + + if (toolOptions) toolOptions.classList.remove('hidden'); + if (fileControls) fileControls.classList.remove('hidden'); + } else { + if (toolOptions) toolOptions.classList.add('hidden'); + if (fileControls) fileControls.classList.add('hidden'); + } +} + +function 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) { + pageState.files.push(...pdfFiles); + updateUI(); + } + } +} + +async function processFiles() { + if (pageState.files.length === 0) { + showAlert('No Files', 'Please select at least one PDF file.'); + return; + } + + const loaderModal = document.getElementById('loader-modal'); + const loaderText = document.getElementById('loader-text'); + + try { + if (pageState.files.length === 1) { + if (loaderModal) loaderModal.classList.remove('hidden'); + if (loaderText) + loaderText.textContent = 'Converting fonts to outlines...'; + + const file = pageState.files[0]; + const resultBlob = await convertFileToOutlines(file, (msg) => { + if (loaderText) loaderText.textContent = msg; + }); + + const baseName = file.name.replace(/\.pdf$/i, ''); + downloadFile(resultBlob, `${baseName}_outlined.pdf`); + if (loaderModal) loaderModal.classList.add('hidden'); + } else { + if (loaderModal) loaderModal.classList.remove('hidden'); + if (loaderText) loaderText.textContent = 'Processing multiple PDFs...'; + + const zip = new JSZip(); + let processedCount = 0; + + for (let i = 0; i < pageState.files.length; i++) { + const file = pageState.files[i]; + if (loaderText) + loaderText.textContent = `Processing ${i + 1}/${pageState.files.length}: ${file.name}...`; + + try { + const resultBlob = await convertFileToOutlines(file, () => {}); + const arrayBuffer = await resultBlob.arrayBuffer(); + const baseName = file.name.replace(/\.pdf$/i, ''); + zip.file(`${baseName}_outlined.pdf`, arrayBuffer); + processedCount++; + } catch (e) { + console.error(`Error processing ${file.name}:`, e); + } + } + + if (processedCount > 0) { + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, 'outlined_pdfs.zip'); + showAlert( + 'Success', + `Processed ${processedCount} PDFs.`, + 'success', + () => { + resetState(); + } + ); + } else { + showAlert('Error', 'No PDFs could be processed.'); + } + if (loaderModal) loaderModal.classList.add('hidden'); + } + } catch (e: unknown) { + console.error(e); + if (loaderModal) loaderModal.classList.add('hidden'); + const errorMessage = + e instanceof Error ? e.message : 'An unexpected error occurred.'; + showAlert('Error', errorMessage); + } +} + +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 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', 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'); + handleFileSelect(e.dataTransfer?.files); + }); + + fileInput.addEventListener('click', function () { + fileInput.value = ''; + }); + } + + if (processBtn) { + processBtn.addEventListener('click', processFiles); + } + + if (addMoreBtn) { + addMoreBtn.addEventListener('click', function () { + fileInput.value = ''; + fileInput.click(); + }); + } + + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', function () { + resetState(); + }); + } +}); diff --git a/src/js/main.ts b/src/js/main.ts index 74bbf28..989111a 100644 --- a/src/js/main.ts +++ b/src/js/main.ts @@ -271,6 +271,8 @@ const init = async () => { 'Remove Metadata': 'tools:removeMetadata', 'Change Permissions': 'tools:changePermissions', 'Email to PDF': 'tools:emailToPdf', + 'Font to Outline': 'tools:fontToOutline', + 'Deskew PDF': 'tools:deskewPdf', }; // Homepage-only tool grid rendering (not used on individual tool pages) diff --git a/src/js/utils/ghostscript-loader.ts b/src/js/utils/ghostscript-loader.ts index e08e10a..1f56384 100644 --- a/src/js/utils/ghostscript-loader.ts +++ b/src/js/utils/ghostscript-loader.ts @@ -42,7 +42,7 @@ export async function convertToPdfA( gs = cachedGsModule; } else { const gsBaseUrl = getWasmBaseUrl('ghostscript'); - gs = await loadWASM({ + gs = (await loadWASM({ locateFile: (path: string) => { if (path.endsWith('.wasm')) { return gsBaseUrl + 'gs.wasm'; @@ -51,7 +51,7 @@ export async function convertToPdfA( }, print: (text: string) => console.log('[GS]', text), printErr: (text: string) => console.error('[GS Error]', text), - }) as GhostscriptModule; + })) as GhostscriptModule; cachedGsModule = gs; } @@ -76,16 +76,24 @@ export async function convertToPdfA( const response = await fetchWasmFile('ghostscript', iccFileName); if (!response.ok) { - throw new Error(`Failed to fetch ICC profile: ${iccFileName}. Ensure it is in your assets folder.`); + throw new Error( + `Failed to fetch ICC profile: ${iccFileName}. Ensure it is in your assets folder.` + ); } const iccData = new Uint8Array(await response.arrayBuffer()); - console.log('[Ghostscript] sRGB v2 ICC profile loaded:', iccData.length, 'bytes'); + console.log( + '[Ghostscript] sRGB v2 ICC profile loaded:', + iccData.length, + 'bytes' + ); gs.FS.writeFile(iccPath, iccData); console.log('[Ghostscript] sRGB ICC profile written to FS:', iccPath); - const iccHex = Array.from(iccData).map(b => b.toString(16).padStart(2, '0')).join(''); + const iccHex = Array.from(iccData) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); console.log('[Ghostscript] ICC profile hex length:', iccHex.length); const pdfaSubtype = level === 'PDF/A-1b' ? '/GTS_PDFA1' : '/GTS_PDFA'; @@ -114,7 +122,9 @@ export async function convertToPdfA( `; gs.FS.writeFile(pdfaDefPath, pdfaPS); - console.log('[Ghostscript] PDFA PostScript created with embedded ICC hex data'); + console.log( + '[Ghostscript] PDFA PostScript created with embedded ICC hex data' + ); } catch (e) { console.error('[Ghostscript] Failed to setup PDF/A assets:', e); throw new Error('Conversion failed: could not create PDF/A definition'); @@ -163,10 +173,26 @@ export async function convertToPdfA( console.log('[Ghostscript] Exit code:', exitCode); if (exitCode !== 0) { - try { gs.FS.unlink(inputPath); } catch { /* ignore */ } - try { gs.FS.unlink(outputPath); } catch { /* ignore */ } - try { gs.FS.unlink(iccPath); } catch { /* ignore */ } - try { gs.FS.unlink(pdfaDefPath); } catch { /* ignore */ } + try { + gs.FS.unlink(inputPath); + } catch { + /* ignore */ + } + try { + gs.FS.unlink(outputPath); + } catch { + /* ignore */ + } + try { + gs.FS.unlink(iccPath); + } catch { + /* ignore */ + } + try { + gs.FS.unlink(pdfaDefPath); + } catch { + /* ignore */ + } throw new Error(`Ghostscript conversion failed with exit code ${exitCode}`); } @@ -182,14 +208,32 @@ export async function convertToPdfA( } // Cleanup - try { gs.FS.unlink(inputPath); } catch { /* ignore */ } - try { gs.FS.unlink(outputPath); } catch { /* ignore */ } - try { gs.FS.unlink(iccPath); } catch { /* ignore */ } - try { gs.FS.unlink(pdfaDefPath); } catch { /* ignore */ } + try { + gs.FS.unlink(inputPath); + } catch { + /* ignore */ + } + try { + gs.FS.unlink(outputPath); + } catch { + /* ignore */ + } + try { + gs.FS.unlink(iccPath); + } catch { + /* ignore */ + } + try { + gs.FS.unlink(pdfaDefPath); + } catch { + /* ignore */ + } if (level !== 'PDF/A-1b') { onProgress?.('Post-processing for transparency compliance...'); - console.log('[Ghostscript] Adding Group dictionaries to pages for transparency compliance...'); + console.log( + '[Ghostscript] Adding Group dictionaries to pages for transparency compliance...' + ); try { output = await addPageGroupDictionaries(output); @@ -202,10 +246,12 @@ export async function convertToPdfA( return output; } -async function addPageGroupDictionaries(pdfData: Uint8Array): Promise { +async function addPageGroupDictionaries( + pdfData: Uint8Array +): Promise { const pdfDoc = await PDFDocument.load(pdfData, { ignoreEncryption: true, - updateMetadata: false + updateMetadata: false, }); const catalog = pdfDoc.catalog; @@ -227,12 +273,22 @@ async function addPageGroupDictionaries(pdfData: Uint8Array): Promise { - if (obj instanceof PDFDict || (obj && typeof obj === 'object' && 'dict' in obj)) { - const dict = 'dict' in obj ? (obj as { dict: PDFDict }).dict : obj as PDFDict; + if ( + obj instanceof PDFDict || + (obj && typeof obj === 'object' && 'dict' in obj) + ) { + const dict = + 'dict' in obj ? (obj as { dict: PDFDict }).dict : (obj as PDFDict); const subtype = dict.get(PDFName.of('Subtype')); if (subtype instanceof PDFName && subtype.decodeText() === 'Form') { @@ -290,8 +353,100 @@ export async function convertFileToPdfA( const arrayBuffer = await file.arrayBuffer(); const pdfData = new Uint8Array(arrayBuffer); const result = await convertToPdfA(pdfData, level, onProgress); - // Copy to regular ArrayBuffer to avoid SharedArrayBuffer issues const copy = new Uint8Array(result.length); copy.set(result); return new Blob([copy], { type: 'application/pdf' }); -} \ No newline at end of file +} + +export async function convertFontsToOutlines( + pdfData: Uint8Array, + onProgress?: (msg: string) => void +): Promise { + onProgress?.('Loading Ghostscript...'); + + let gs: GhostscriptModule; + + if (cachedGsModule) { + gs = cachedGsModule; + } else { + const gsBaseUrl = getWasmBaseUrl('ghostscript'); + gs = (await loadWASM({ + locateFile: (path: string) => { + if (path.endsWith('.wasm')) { + return gsBaseUrl + 'gs.wasm'; + } + return path; + }, + print: (text: string) => console.log('[GS]', text), + printErr: (text: string) => console.error('[GS Error]', text), + })) as GhostscriptModule; + cachedGsModule = gs; + } + + const inputPath = '/tmp/input.pdf'; + const outputPath = '/tmp/output.pdf'; + + gs.FS.writeFile(inputPath, pdfData); + + onProgress?.('Converting fonts to outlines...'); + + const args = [ + '-dNOSAFER', + '-dBATCH', + '-dNOPAUSE', + '-sDEVICE=pdfwrite', + '-dNoOutputFonts', + '-dCompressPages=true', + '-dAutoRotatePages=/None', + `-sOutputFile=${outputPath}`, + inputPath, + ]; + + let exitCode: number; + try { + exitCode = gs.callMain(args); + } catch (e) { + try { + gs.FS.unlink(inputPath); + } catch {} + throw new Error(`Ghostscript threw an exception: ${e}`); + } + + if (exitCode !== 0) { + try { + gs.FS.unlink(inputPath); + } catch {} + try { + gs.FS.unlink(outputPath); + } catch {} + throw new Error(`Ghostscript conversion failed with exit code ${exitCode}`); + } + + let output: Uint8Array; + try { + output = gs.FS.readFile(outputPath); + } catch (e) { + throw new Error('Ghostscript did not produce output file'); + } + + try { + gs.FS.unlink(inputPath); + } catch {} + try { + gs.FS.unlink(outputPath); + } catch {} + + return output; +} + +export async function convertFileToOutlines( + file: File, + onProgress?: (msg: string) => void +): Promise { + const arrayBuffer = await file.arrayBuffer(); + const pdfData = new Uint8Array(arrayBuffer); + const result = await convertFontsToOutlines(pdfData, onProgress); + const copy = new Uint8Array(result.length); + copy.set(result); + return new Blob([copy], { type: 'application/pdf' }); +} diff --git a/src/js/utils/helpers.ts b/src/js/utils/helpers.ts index b760234..b5afd5f 100644 --- a/src/js/utils/helpers.ts +++ b/src/js/utils/helpers.ts @@ -306,3 +306,157 @@ export function escapeHtml(text: string): string { }; return text.replace(/[&<>"']/g, (m) => map[m]); } + +export function uint8ArrayToBase64(bytes: Uint8Array): string { + const CHUNK_SIZE = 0x8000; + const chunks: string[] = []; + for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { + const chunk = bytes.subarray(i, Math.min(i + CHUNK_SIZE, bytes.length)); + chunks.push(String.fromCharCode(...chunk)); + } + return btoa(chunks.join('')); +} + +export function sanitizeEmailHtml(html: string): string { + if (!html) return html; + + let sanitized = html; + + sanitized = sanitized.replace(/]*>[\s\S]*?<\/head>/gi, ''); + sanitized = sanitized.replace(/]*>[\s\S]*?<\/style>/gi, ''); + sanitized = sanitized.replace(/]*>[\s\S]*?<\/script>/gi, ''); + sanitized = sanitized.replace(/]*>/gi, ''); + sanitized = sanitized.replace(/\s+style=["'][^"']*["']/gi, ''); + sanitized = sanitized.replace(/\s+class=["'][^"']*["']/gi, ''); + sanitized = sanitized.replace(/\s+data-[a-z-]+=["'][^"']*["']/gi, ''); + sanitized = sanitized.replace( + /]*(?:width=["']1["'][^>]*height=["']1["']|height=["']1["'][^>]*width=["']1["'])[^>]*\/?>/gi, + '' + ); + sanitized = sanitized.replace( + /href=["']https?:\/\/[^"']*safelinks\.protection\.outlook\.com[^"']*url=([^&"']+)[^"']*["']/gi, + (match, encodedUrl) => { + try { + const decodedUrl = decodeURIComponent(encodedUrl); + return `href="${decodedUrl}"`; + } catch { + return match; + } + } + ); + sanitized = sanitized.replace(/\s+originalsrc=["'][^"']*["']/gi, ''); + sanitized = sanitized.replace( + /href=["']([^"']{500,})["']/gi, + (match, url) => { + const baseUrl = url.split('?')[0]; + if (baseUrl && baseUrl.length < 200) { + return `href="${baseUrl}"`; + } + return `href="${url.substring(0, 200)}"`; + } + ); + + sanitized = sanitized.replace( + /\s+(cellpadding|cellspacing|bgcolor|border|valign|align|width|height|role|dir|id)=["'][^"']*["']/gi, + '' + ); + sanitized = sanitized.replace(/<\/?table[^>]*>/gi, '
'); + sanitized = sanitized.replace(/<\/?tbody[^>]*>/gi, ''); + sanitized = sanitized.replace(/<\/?thead[^>]*>/gi, ''); + sanitized = sanitized.replace(/<\/?tfoot[^>]*>/gi, ''); + sanitized = sanitized.replace(/]*>/gi, '
'); + sanitized = sanitized.replace(/<\/tr>/gi, '
'); + sanitized = sanitized.replace(/]*>/gi, ' '); + sanitized = sanitized.replace(/<\/td>/gi, ' '); + sanitized = sanitized.replace(/]*>/gi, ' '); + sanitized = sanitized.replace(/<\/th>/gi, ' '); + sanitized = sanitized.replace(/
\s*<\/div>/gi, ''); + sanitized = sanitized.replace(/\s*<\/span>/gi, ''); + sanitized = sanitized.replace(/(
)+/gi, '
'); + sanitized = sanitized.replace(/(<\/div>)+/gi, '
'); + sanitized = sanitized.replace( + /]*href=["']\s*["'][^>]*>([^<]*)<\/a>/gi, + '$1' + ); + + const MAX_HTML_SIZE = 100000; + if (sanitized.length > MAX_HTML_SIZE) { + const truncateAt = sanitized.lastIndexOf('
', MAX_HTML_SIZE); + if (truncateAt > MAX_HTML_SIZE / 2) { + sanitized = sanitized.substring(0, truncateAt) + '
'; + } else { + sanitized = sanitized.substring(0, MAX_HTML_SIZE) + '...'; + } + } + + return sanitized; +} + +/** + * Formats a raw RFC 2822 date string into a nicer human-readable format, + * while preserving the original timezone and time. + * Example input: "Sun, 8 Jan 2017 20:37:44 +0200" + * Example output: "Sunday, January 8, 2017 at 8:37 PM (+0200)" + */ +export function formatRawDate(raw: string): string { + try { + const match = raw.match( + /([A-Za-z]{3}),\s+(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2})(?::(\d{2}))?\s+([+-]\d{4})/ + ); + + if (match) { + const [ + , + dayAbbr, + dom, + monthAbbr, + year, + hoursStr, + minsStr, + secsStr, + timezone, + ] = match; + + const days: Record = { + Sun: 'Sunday', + Mon: 'Monday', + Tue: 'Tuesday', + Wed: 'Wednesday', + Thu: 'Thursday', + Fri: 'Friday', + Sat: 'Saturday', + }; + const months: Record = { + Jan: 'January', + Feb: 'February', + Mar: 'March', + Apr: 'April', + May: 'May', + Jun: 'June', + Jul: 'July', + Aug: 'August', + Sep: 'September', + Oct: 'October', + Nov: 'November', + Dec: 'December', + }; + + const fullDay = days[dayAbbr] || dayAbbr; + const fullMonth = months[monthAbbr] || monthAbbr; + + let hours = parseInt(hoursStr, 10); + const ampm = hours >= 12 ? 'PM' : 'AM'; + hours = hours % 12; + hours = hours ? hours : 12; + const tzSign = timezone.substring(0, 1); + const tzHours = timezone.substring(1, 3); + const tzMins = timezone.substring(3, 5); + const formattedTz = `UTC${tzSign}${tzHours}:${tzMins}`; + + return `${fullDay}, ${fullMonth} ${dom}, ${year} at ${hours}:${minsStr} ${ampm} (${formattedTz})`; + } + } catch (e) { + // Fallback to raw string if parsing fails + } + return raw; +} diff --git a/src/pages/deskew-pdf.html b/src/pages/deskew-pdf.html new file mode 100644 index 0000000..650dfbf --- /dev/null +++ b/src/pages/deskew-pdf.html @@ -0,0 +1,677 @@ + + + + + + + Deskew PDF Online Free - Straighten Scanned PDFs | BentoPDF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +

+ Deskew PDF Free Online - Straighten Tilted Scans +

+

+ Automatically detect and correct skewed pages in scanned PDFs. Uses + advanced image processing to straighten tilted documents. +

+ +
+
+ +

+ Click to select files or drag + and drop +

+

One or more PDF files

+

+ Your files never leave your device. +

+
+ +
+ + + +
+ + + + +
+
+ + + + + +
+

+ How It Works +

+
+
+
+ 1 +
+
+

Upload PDF

+

+ Select your scanned PDF with tilted pages +

+
+
+
+
+ 2 +
+
+

Auto-Detect

+

OpenCV analyzes and detects skew angles

+
+
+
+
+ 3 +
+
+

Download

+

Get your straightened PDF instantly

+
+
+
+
+ +
+

+ Related PDF Tools +

+ +
+ +
+

+ Frequently Asked Questions +

+
+
+ + What is PDF deskewing? + + +

+ Deskewing is the process of correcting tilted or rotated pages in + scanned documents. When you scan a document, it's common for pages + to be slightly skewed. This tool automatically detects and corrects + that skew. +

+
+
+ + How accurate is the skew detection? + + +

+ Our tool uses OpenCV's advanced image processing algorithms to + detect skew angles with high precision. It works best on documents + with clear text content. +

+
+
+ + Are my files private? + + +

+ Yes! All processing happens entirely in your browser using + WebAssembly. Your files never leave your device. +

+
+
+
+ + + + + + + + + + + + + + + + + diff --git a/src/pages/font-to-outline.html b/src/pages/font-to-outline.html new file mode 100644 index 0000000..f8ad758 --- /dev/null +++ b/src/pages/font-to-outline.html @@ -0,0 +1,667 @@ + + + + + + + + Font to Outline PDF Online Free - Convert Fonts to Paths | BentoPDF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +

+ Font to Outline Free Online - Convert Fonts to Paths +

+

+ Convert all fonts in your PDF to vector outlines/paths. Ensures + consistent rendering across all devices regardless of font + availability. +

+ +
+
+ +

+ Click to select files or drag + and drop +

+

One or more PDF files

+

+ Your files never leave your device. +

+
+ +
+ + + +
+ + +
+
+ + + + + +
+

+ How It Works +

+
+
+
+ 1 +
+
+

Upload PDF

+

+ Select your PDF file with embedded fonts +

+
+
+
+
+ 2 +
+
+

Convert

+

+ Ghostscript converts all fonts to vector paths +

+
+
+
+
+ 3 +
+
+

Download

+

Get your font-independent PDF instantly

+
+
+
+
+ +
+

+ Related PDF Tools +

+ +
+ +
+

+ Frequently Asked Questions +

+
+
+ + What does "font to outline" mean? + + +

+ Converting fonts to outlines means transforming text characters from + font-based representations into vector paths/curves. This ensures + the PDF looks identical on any device, even if the original fonts + aren't installed. +

+
+
+ + When should I use this tool? + + +

+ Use this tool when preparing PDFs for professional printing, sharing + documents with special fonts, or when you need to ensure consistent + appearance across different systems and devices. +

+
+
+ + Will the text still be selectable? + + +

+ No. After conversion, text becomes vector graphics and is no longer + selectable or searchable. If you need searchable text, consider + using the OCR tool after conversion. +

+
+
+ + Are my files private? + + +

+ Yes! All processing happens entirely in your browser using + WebAssembly. Your files never leave your device. +

+
+
+
+ + + + + + + + + + + + + + + + + diff --git a/vite.config.ts b/vite.config.ts index e3e4c42..c4caa12 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -423,6 +423,11 @@ export default defineConfig(({ mode }) => { 'src/pages/validate-signature-pdf.html' ), 'email-to-pdf': resolve(__dirname, 'src/pages/email-to-pdf.html'), + 'font-to-outline': resolve( + __dirname, + 'src/pages/font-to-outline.html' + ), + 'deskew-pdf': resolve(__dirname, 'src/pages/deskew-pdf.html'), }, }, }, From c5799954dce35968389263a179a59a399c069ad1 Mon Sep 17 00:00:00 2001 From: abdullahalam123 Date: Sat, 10 Jan 2026 13:09:52 +0530 Subject: [PATCH 15/73] fix(ocr): improve text layer alignment with width-based font sizing - Create new hocr-transform.ts utility for parsing hOCR output - Add line-aware text processing with baseline and rotation support - Implement width-based font size calculation to match word bounding boxes - Fix text selection not covering full characters issue - Add proper type definitions for OcrLine, OcrPage, WordTransform - Support RTL languages and CJK word break handling --- src/js/logic/ocr-pdf-page.ts | 1036 ++++++++++++++++++-------------- src/js/types/ocr-pdf-type.ts | 46 +- src/js/utils/hocr-transform.ts | 266 ++++++++ 3 files changed, 887 insertions(+), 461 deletions(-) create mode 100644 src/js/utils/hocr-transform.ts diff --git a/src/js/logic/ocr-pdf-page.ts b/src/js/logic/ocr-pdf-page.ts index bdadf7a..422ae66 100644 --- a/src/js/logic/ocr-pdf-page.ts +++ b/src/js/logic/ocr-pdf-page.ts @@ -2,556 +2,680 @@ import { tesseractLanguages } from '../config/tesseract-languages.js'; import { showAlert } from '../ui.js'; import { downloadFile, formatBytes, getPDFDocument } from '../utils/helpers.js'; import Tesseract from 'tesseract.js'; -import { PDFDocument as PDFLibDocument, StandardFonts, rgb } from 'pdf-lib'; +import { + PDFDocument as PDFLibDocument, + StandardFonts, + rgb, + PDFFont, +} from 'pdf-lib'; 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'; +import { OcrState, OcrLine, OcrPage } from '@/types'; +import { + parseHocrDocument, + calculateWordTransform, + calculateSpaceTransform, +} from '../utils/hocr-transform.js'; -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(); const pageState: OcrState = { - file: null, - searchablePdfBytes: null, + file: null, + searchablePdfBytes: null, }; const whitelistPresets: Record = { - alphanumeric: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,!?-\'"', - 'numbers-currency': '0123456789$€£¥.,- ', - 'letters-only': 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ', - 'numbers-only': '0123456789', - invoice: '0123456789$.,/-#: ', - forms: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,()-_/@#:', + alphanumeric: + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,!?-\'"', + 'numbers-currency': '0123456789$€£¥.,- ', + 'letters-only': 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ', + 'numbers-only': '0123456789', + invoice: '0123456789$.,/-#: ', + forms: + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,()-_/@#:', }; -function parseHOCR(hocrText: string): OcrWord[] { - const parser = new DOMParser(); - const doc = parser.parseFromString(hocrText, 'text/html'); - const words: OcrWord[] = []; +function drawOcrTextLayer( + page: ReturnType, + ocrPage: OcrPage, + pageHeight: number, + primaryFont: PDFFont, + latinFont: PDFFont +): void { + ocrPage.lines.forEach(function (line: OcrLine) { + const words = line.words; - const wordElements = doc.querySelectorAll('.ocrx_word'); + for (let i = 0; i < words.length; i++) { + const word = words[i]; + const text = word.text.replace( + /[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\uFEFF]/g, + '' + ); - wordElements.forEach(function (wordEl) { - const titleAttr = wordEl.getAttribute('title'); - const text = wordEl.textContent?.trim() || ''; + if (!text.trim()) continue; - if (!titleAttr || !text) return; + const hasNonLatin = /[^\u0000-\u007F]/.test(text); + const font = hasNonLatin ? primaryFont : latinFont; - const bboxMatch = titleAttr.match(/bbox (\d+) (\d+) (\d+) (\d+)/); - const confMatch = titleAttr.match(/x_wconf (\d+)/); + if (!font) { + console.warn('Font not available for text: "' + text + '"'); + continue; + } - if (bboxMatch) { - words.push({ - text: text, - bbox: { - x0: parseInt(bboxMatch[1]), - y0: parseInt(bboxMatch[2]), - x1: parseInt(bboxMatch[3]), - y1: parseInt(bboxMatch[4]), - }, - confidence: confMatch ? parseInt(confMatch[1]) : 0, - }); + const transform = calculateWordTransform( + word, + line, + pageHeight, + (txt: string, size: number) => { + try { + return font.widthOfTextAtSize(txt, size); + } catch { + return 0; + } } - }); + ); - return words; + if (transform.fontSize <= 0) continue; + + try { + page.drawText(text, { + x: transform.x, + y: transform.y, + font, + size: transform.fontSize, + color: rgb(0, 0, 0), + opacity: 0, + }); + } catch (error) { + console.warn(`Could not draw text "${text}":`, error); + } + + if (line.injectWordBreaks && i < words.length - 1) { + const nextWord = words[i + 1]; + const spaceTransform = calculateSpaceTransform( + word, + nextWord, + line, + pageHeight, + (size: number) => { + try { + return font.widthOfTextAtSize(' ', size); + } catch { + return 0; + } + } + ); + + if (spaceTransform && spaceTransform.horizontalScale > 0.1) { + try { + page.drawText(' ', { + x: spaceTransform.x, + y: spaceTransform.y, + font, + size: spaceTransform.fontSize, + color: rgb(0, 0, 0), + opacity: 0, + }); + } catch { + console.warn(`Could not draw space between words`); + } + } + } + } + }); } function binarizeCanvas(ctx: CanvasRenderingContext2D) { - const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height); - const data = imageData.data; - for (let i = 0; i < data.length; i += 4) { - const brightness = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]; - const color = brightness > 128 ? 255 : 0; - data[i] = data[i + 1] = data[i + 2] = color; - } - ctx.putImageData(imageData, 0, 0); + const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height); + const data = imageData.data; + for (let i = 0; i < data.length; i += 4) { + const brightness = + 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]; + const color = brightness > 128 ? 255 : 0; + data[i] = data[i + 1] = data[i + 2] = color; + } + ctx.putImageData(imageData, 0, 0); } function updateProgress(status: string, progress: number) { - const progressBar = document.getElementById('progress-bar'); - const progressStatus = document.getElementById('progress-status'); - const progressLog = document.getElementById('progress-log'); + const progressBar = document.getElementById('progress-bar'); + const progressStatus = document.getElementById('progress-status'); + const progressLog = document.getElementById('progress-log'); - if (!progressBar || !progressStatus || !progressLog) return; + if (!progressBar || !progressStatus || !progressLog) return; - progressStatus.textContent = status; - progressBar.style.width = `${Math.min(100, progress * 100)}%`; + progressStatus.textContent = status; + progressBar.style.width = `${Math.min(100, progress * 100)}%`; - const logMessage = `Status: ${status}`; - progressLog.textContent += logMessage + '\n'; - progressLog.scrollTop = progressLog.scrollHeight; + const logMessage = `Status: ${status}`; + progressLog.textContent += logMessage + '\n'; + progressLog.scrollTop = progressLog.scrollHeight; } function resetState() { - pageState.file = null; - pageState.searchablePdfBytes = null; + pageState.file = null; + pageState.searchablePdfBytes = null; - const fileDisplayArea = document.getElementById('file-display-area'); - if (fileDisplayArea) fileDisplayArea.innerHTML = ''; + const fileDisplayArea = document.getElementById('file-display-area'); + if (fileDisplayArea) fileDisplayArea.innerHTML = ''; - const toolOptions = document.getElementById('tool-options'); - if (toolOptions) toolOptions.classList.add('hidden'); + const toolOptions = document.getElementById('tool-options'); + if (toolOptions) toolOptions.classList.add('hidden'); - const ocrProgress = document.getElementById('ocr-progress'); - if (ocrProgress) ocrProgress.classList.add('hidden'); + const ocrProgress = document.getElementById('ocr-progress'); + if (ocrProgress) ocrProgress.classList.add('hidden'); - const ocrResults = document.getElementById('ocr-results'); - if (ocrResults) ocrResults.classList.add('hidden'); + const ocrResults = document.getElementById('ocr-results'); + if (ocrResults) ocrResults.classList.add('hidden'); - const progressLog = document.getElementById('progress-log'); - if (progressLog) progressLog.textContent = ''; + const progressLog = document.getElementById('progress-log'); + if (progressLog) progressLog.textContent = ''; - const progressBar = document.getElementById('progress-bar'); - if (progressBar) progressBar.style.width = '0%'; + const progressBar = document.getElementById('progress-bar'); + if (progressBar) progressBar.style.width = '0%'; - const fileInput = document.getElementById('file-input') as HTMLInputElement; - if (fileInput) fileInput.value = ''; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; - // Reset selected languages - const langCheckboxes = document.querySelectorAll('.lang-checkbox') as NodeListOf; - langCheckboxes.forEach(function (cb) { cb.checked = false; }); + // Reset selected languages + const langCheckboxes = document.querySelectorAll( + '.lang-checkbox' + ) as NodeListOf; + langCheckboxes.forEach(function (cb) { + cb.checked = false; + }); - const selectedLangsDisplay = document.getElementById('selected-langs-display'); - if (selectedLangsDisplay) selectedLangsDisplay.textContent = 'None'; + const selectedLangsDisplay = document.getElementById( + 'selected-langs-display' + ); + if (selectedLangsDisplay) selectedLangsDisplay.textContent = 'None'; - const processBtn = document.getElementById('process-btn') as HTMLButtonElement; - if (processBtn) processBtn.disabled = true; + const processBtn = document.getElementById( + 'process-btn' + ) as HTMLButtonElement; + if (processBtn) processBtn.disabled = true; } async function runOCR() { - const selectedLangs = Array.from( - document.querySelectorAll('.lang-checkbox:checked') - ).map(function (cb) { return (cb as HTMLInputElement).value; }); + const selectedLangs = Array.from( + document.querySelectorAll('.lang-checkbox:checked') + ).map(function (cb) { + return (cb as HTMLInputElement).value; + }); - const scale = parseFloat( - (document.getElementById('ocr-resolution') as HTMLSelectElement).value + const scale = parseFloat( + (document.getElementById('ocr-resolution') as HTMLSelectElement).value + ); + const binarize = (document.getElementById('ocr-binarize') as HTMLInputElement) + .checked; + const whitelist = ( + document.getElementById('ocr-whitelist') as HTMLInputElement + ).value; + + if (selectedLangs.length === 0) { + showAlert( + 'No Languages Selected', + 'Please select at least one language for OCR.' ); - const binarize = (document.getElementById('ocr-binarize') as HTMLInputElement).checked; - const whitelist = (document.getElementById('ocr-whitelist') as HTMLInputElement).value; + return; + } - if (selectedLangs.length === 0) { - showAlert('No Languages Selected', 'Please select at least one language for OCR.'); - return; + if (!pageState.file) { + showAlert('No File', 'Please upload a PDF file first.'); + return; + } + + const langString = selectedLangs.join('+'); + + const toolOptions = document.getElementById('tool-options'); + const ocrProgress = document.getElementById('ocr-progress'); + + if (toolOptions) toolOptions.classList.add('hidden'); + if (ocrProgress) ocrProgress.classList.remove('hidden'); + + try { + const worker = await Tesseract.createWorker(langString, 1, { + logger: function (m: { status: string; progress: number }) { + updateProgress(m.status, m.progress || 0); + }, + }); + + await worker.setParameters({ + tessjs_create_hocr: '1', + tessedit_pageseg_mode: Tesseract.PSM.AUTO, + }); + + if (whitelist) { + await worker.setParameters({ + tessedit_char_whitelist: whitelist, + }); } - if (!pageState.file) { - showAlert('No File', 'Please upload a PDF file first.'); - return; - } + const arrayBuffer = await pageState.file.arrayBuffer(); + const pdf = await getPDFDocument({ data: arrayBuffer }).promise; + const newPdfDoc = await PDFLibDocument.create(); - const langString = selectedLangs.join('+'); + newPdfDoc.registerFontkit(fontkit); - const toolOptions = document.getElementById('tool-options'); - const ocrProgress = document.getElementById('ocr-progress'); + updateProgress('Loading fonts...', 0); - if (toolOptions) toolOptions.classList.add('hidden'); - if (ocrProgress) ocrProgress.classList.remove('hidden'); + const cjkLangs = ['jpn', 'chi_sim', 'chi_tra', 'kor']; + const indicLangs = [ + 'hin', + 'ben', + 'guj', + 'kan', + 'mal', + 'ori', + 'pan', + 'tam', + 'tel', + 'sin', + ]; + const priorityLangs = [...cjkLangs, ...indicLangs, 'ara', 'rus', 'ukr']; + + const primaryLang = + selectedLangs.find(function (l) { + return priorityLangs.includes(l); + }) || + selectedLangs[0] || + 'eng'; + + const hasCJK = selectedLangs.some(function (l) { + return cjkLangs.includes(l); + }); + const hasIndic = selectedLangs.some(function (l) { + return indicLangs.includes(l); + }); + const hasLatin = + selectedLangs.some(function (l) { + return !priorityLangs.includes(l); + }) || selectedLangs.includes('eng'); + const isIndicPlusLatin = hasIndic && hasLatin && !hasCJK; + + let primaryFont; + let latinFont; try { - const worker = await Tesseract.createWorker(langString, 1, { - logger: function (m: { status: string; progress: number }) { - updateProgress(m.status, m.progress || 0); - }, + if (isIndicPlusLatin) { + const [scriptFontBytes, latinFontBytes] = await Promise.all([ + getFontForLanguage(primaryLang), + getFontForLanguage('eng'), + ]); + primaryFont = await newPdfDoc.embedFont(scriptFontBytes, { + subset: false, }); - - await worker.setParameters({ - tessjs_create_hocr: '1', - tessedit_pageseg_mode: Tesseract.PSM.AUTO, + latinFont = await newPdfDoc.embedFont(latinFontBytes, { + subset: false, }); - - if (whitelist) { - await worker.setParameters({ - tessedit_char_whitelist: whitelist, - }); - } - - const arrayBuffer = await pageState.file.arrayBuffer(); - const pdf = await getPDFDocument({ data: arrayBuffer }).promise; - const newPdfDoc = await PDFLibDocument.create(); - - newPdfDoc.registerFontkit(fontkit); - - updateProgress('Loading fonts...', 0); - - const cjkLangs = ['jpn', 'chi_sim', 'chi_tra', 'kor']; - const indicLangs = ['hin', 'ben', 'guj', 'kan', 'mal', 'ori', 'pan', 'tam', 'tel', 'sin']; - const priorityLangs = [...cjkLangs, ...indicLangs, 'ara', 'rus', 'ukr']; - - const primaryLang = selectedLangs.find(function (l) { return priorityLangs.includes(l); }) || selectedLangs[0] || 'eng'; - - const hasCJK = selectedLangs.some(function (l) { return cjkLangs.includes(l); }); - const hasIndic = selectedLangs.some(function (l) { return indicLangs.includes(l); }); - const hasLatin = selectedLangs.some(function (l) { return !priorityLangs.includes(l); }) || selectedLangs.includes('eng'); - const isIndicPlusLatin = hasIndic && hasLatin && !hasCJK; - - let primaryFont; - let latinFont; - - try { - if (isIndicPlusLatin) { - const [scriptFontBytes, latinFontBytes] = await Promise.all([ - getFontForLanguage(primaryLang), - getFontForLanguage('eng') - ]); - primaryFont = await newPdfDoc.embedFont(scriptFontBytes, { subset: false }); - latinFont = await newPdfDoc.embedFont(latinFontBytes, { subset: false }); - } else { - const fontBytes = await getFontForLanguage(primaryLang); - primaryFont = await newPdfDoc.embedFont(fontBytes, { subset: false }); - latinFont = primaryFont; - } - } catch (e) { - console.error('Font loading failed, falling back to Helvetica', e); - primaryFont = await newPdfDoc.embedFont(StandardFonts.Helvetica); - latinFont = primaryFont; - showAlert('Font Warning', 'Could not load the specific font for this language. Some characters may not appear correctly.'); - } - - let fullText = ''; - - for (let i = 1; i <= pdf.numPages; i++) { - updateProgress(`Processing page ${i} of ${pdf.numPages}`, (i - 1) / pdf.numPages); - - const page = await pdf.getPage(i); - const viewport = page.getViewport({ scale }); - - const canvas = document.createElement('canvas'); - canvas.width = viewport.width; - canvas.height = viewport.height; - const context = canvas.getContext('2d')!; - - await page.render({ canvasContext: context, viewport, canvas }).promise; - - if (binarize) { - binarizeCanvas(context); - } - - const result = await worker.recognize(canvas, {}, { text: true, hocr: true }); - const data = result.data; - - const newPage = newPdfDoc.addPage([viewport.width, viewport.height]); - - const pngImageBytes = await new Promise(function (resolve) { - canvas.toBlob(function (blob) { - const reader = new FileReader(); - reader.onload = function () { - resolve(new Uint8Array(reader.result as ArrayBuffer)); - }; - reader.readAsArrayBuffer(blob!); - }, 'image/png'); - }); - - const pngImage = await newPdfDoc.embedPng(pngImageBytes); - newPage.drawImage(pngImage, { - x: 0, - y: 0, - width: viewport.width, - height: viewport.height, - }); - - if (data.hocr) { - const words = parseHOCR(data.hocr); - - 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, ''); - - if (!text.trim()) return; - - const hasNonLatin = /[^\u0000-\u007F]/.test(text); - const font = hasNonLatin ? primaryFont : latinFont; - - if (!font) { - console.warn(`Font not available for text: "${text}"`); - return; - } - - const bboxWidth = x1 - x0; - const bboxHeight = y1 - y0; - - if (bboxWidth <= 0 || bboxHeight <= 0) { - return; - } - - let fontSize = bboxHeight * 0.9; - try { - let textWidth = font.widthOfTextAtSize(text, fontSize); - while (textWidth > bboxWidth && fontSize > 1) { - fontSize -= 0.5; - textWidth = font.widthOfTextAtSize(text, fontSize); - } - } catch (error) { - console.warn(`Could not calculate text width for "${text}":`, error); - return; - } - - try { - newPage.drawText(text, { - x: x0, - y: viewport.height - y1 + (bboxHeight - fontSize) / 2, - font, - size: fontSize, - color: rgb(0, 0, 0), - opacity: 0, - }); - } catch (error) { - console.warn(`Could not draw text "${text}":`, error); - } - }); - } - - fullText += data.text + '\n\n'; - } - - await worker.terminate(); - - pageState.searchablePdfBytes = await newPdfDoc.save(); - - const ocrResults = document.getElementById('ocr-results'); - if (ocrProgress) ocrProgress.classList.add('hidden'); - if (ocrResults) ocrResults.classList.remove('hidden'); - - createIcons({ icons }); - - const textOutput = document.getElementById('ocr-text-output') as HTMLTextAreaElement; - if (textOutput) textOutput.value = fullText.trim(); - + } else { + const fontBytes = await getFontForLanguage(primaryLang); + primaryFont = await newPdfDoc.embedFont(fontBytes, { subset: false }); + latinFont = primaryFont; + } } catch (e) { - console.error(e); - showAlert('OCR Error', 'An error occurred during the OCR process. The worker may have failed to load. Please try again.'); - if (toolOptions) toolOptions.classList.remove('hidden'); - if (ocrProgress) ocrProgress.classList.add('hidden'); + console.error('Font loading failed, falling back to Helvetica', e); + primaryFont = await newPdfDoc.embedFont(StandardFonts.Helvetica); + latinFont = primaryFont; + showAlert( + 'Font Warning', + 'Could not load the specific font for this language. Some characters may not appear correctly.' + ); } + + let fullText = ''; + + for (let i = 1; i <= pdf.numPages; i++) { + updateProgress( + `Processing page ${i} of ${pdf.numPages}`, + (i - 1) / pdf.numPages + ); + + const page = await pdf.getPage(i); + const viewport = page.getViewport({ scale }); + + const canvas = document.createElement('canvas'); + canvas.width = viewport.width; + canvas.height = viewport.height; + const context = canvas.getContext('2d')!; + + await page.render({ canvasContext: context, viewport, canvas }).promise; + + if (binarize) { + binarizeCanvas(context); + } + + const result = await worker.recognize( + canvas, + {}, + { text: true, hocr: true } + ); + const data = result.data; + + const newPage = newPdfDoc.addPage([viewport.width, viewport.height]); + + const pngImageBytes = await new Promise(function (resolve) { + canvas.toBlob(function (blob) { + const reader = new FileReader(); + reader.onload = function () { + resolve(new Uint8Array(reader.result as ArrayBuffer)); + }; + reader.readAsArrayBuffer(blob!); + }, 'image/png'); + }); + + const pngImage = await newPdfDoc.embedPng(pngImageBytes); + newPage.drawImage(pngImage, { + x: 0, + y: 0, + width: viewport.width, + height: viewport.height, + }); + + if (data.hocr) { + const ocrPage = parseHocrDocument(data.hocr); + drawOcrTextLayer( + newPage, + ocrPage, + viewport.height, + primaryFont, + latinFont + ); + } + + fullText += data.text + '\n\n'; + } + + await worker.terminate(); + + pageState.searchablePdfBytes = await newPdfDoc.save(); + + const ocrResults = document.getElementById('ocr-results'); + if (ocrProgress) ocrProgress.classList.add('hidden'); + if (ocrResults) ocrResults.classList.remove('hidden'); + + createIcons({ icons }); + + const textOutput = document.getElementById( + 'ocr-text-output' + ) as HTMLTextAreaElement; + if (textOutput) textOutput.value = fullText.trim(); + } catch (e) { + console.error(e); + showAlert( + 'OCR Error', + 'An error occurred during the OCR process. The worker may have failed to load. Please try again.' + ); + if (toolOptions) toolOptions.classList.remove('hidden'); + if (ocrProgress) ocrProgress.classList.add('hidden'); + } } async function updateUI() { - const fileDisplayArea = document.getElementById('file-display-area'); - const toolOptions = document.getElementById('tool-options'); + const fileDisplayArea = document.getElementById('file-display-area'); + const toolOptions = document.getElementById('tool-options'); - if (!fileDisplayArea) return; + if (!fileDisplayArea) return; - fileDisplayArea.innerHTML = ''; + 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'; + 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 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 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); + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = formatBytes(pageState.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 = ''; - removeBtn.onclick = function () { - resetState(); - }; + const removeBtn = document.createElement('button'); + removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = function () { + resetState(); + }; - fileDiv.append(infoContainer, removeBtn); - fileDisplayArea.appendChild(fileDiv); - createIcons({ icons }); + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + createIcons({ icons }); - if (toolOptions) toolOptions.classList.remove('hidden'); - } else { - if (toolOptions) toolOptions.classList.add('hidden'); - } + if (toolOptions) toolOptions.classList.remove('hidden'); + } else { + if (toolOptions) toolOptions.classList.add('hidden'); + } } 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(); - } + if (files && files.length > 0) { + const file = files[0]; + if ( + file.type === 'application/pdf' || + file.name.toLowerCase().endsWith('.pdf') + ) { + pageState.file = file; + updateUI(); } + } } function populateLanguageList() { - const langList = document.getElementById('lang-list'); - if (!langList) return; + const langList = document.getElementById('lang-list'); + if (!langList) return; - langList.innerHTML = ''; + langList.innerHTML = ''; - Object.entries(tesseractLanguages).forEach(function ([code, name]) { - const label = document.createElement('label'); - label.className = 'flex items-center gap-2 p-2 rounded-md hover:bg-gray-700 cursor-pointer'; + Object.entries(tesseractLanguages).forEach(function ([code, name]) { + const label = document.createElement('label'); + label.className = + 'flex items-center gap-2 p-2 rounded-md hover:bg-gray-700 cursor-pointer'; - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - checkbox.value = code; - checkbox.className = 'lang-checkbox w-4 h-4 rounded text-indigo-600 bg-gray-700 border-gray-600 focus:ring-indigo-500'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.value = code; + checkbox.className = + 'lang-checkbox w-4 h-4 rounded text-indigo-600 bg-gray-700 border-gray-600 focus:ring-indigo-500'; - label.append(checkbox); - label.append(document.createTextNode(' ' + name)); - langList.appendChild(label); - }); + label.append(checkbox); + label.append(document.createTextNode(' ' + name)); + langList.appendChild(label); + }); } document.addEventListener('DOMContentLoaded', function () { - const fileInput = document.getElementById('file-input') as HTMLInputElement; - const dropZone = document.getElementById('drop-zone'); - const processBtn = document.getElementById('process-btn') as HTMLButtonElement; - const backBtn = document.getElementById('back-to-tools'); - const langSearch = document.getElementById('lang-search') as HTMLInputElement; - const langList = document.getElementById('lang-list'); - const selectedLangsDisplay = document.getElementById('selected-langs-display'); - const presetSelect = document.getElementById('whitelist-preset') as HTMLSelectElement; - const whitelistInput = document.getElementById('ocr-whitelist') as HTMLInputElement; - const copyBtn = document.getElementById('copy-text-btn'); - const downloadTxtBtn = document.getElementById('download-txt-btn'); - const downloadPdfBtn = document.getElementById('download-searchable-pdf'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const processBtn = document.getElementById( + 'process-btn' + ) as HTMLButtonElement; + const backBtn = document.getElementById('back-to-tools'); + const langSearch = document.getElementById('lang-search') as HTMLInputElement; + const langList = document.getElementById('lang-list'); + const selectedLangsDisplay = document.getElementById( + 'selected-langs-display' + ); + const presetSelect = document.getElementById( + 'whitelist-preset' + ) as HTMLSelectElement; + const whitelistInput = document.getElementById( + 'ocr-whitelist' + ) as HTMLInputElement; + const copyBtn = document.getElementById('copy-text-btn'); + const downloadTxtBtn = document.getElementById('download-txt-btn'); + const downloadPdfBtn = document.getElementById('download-searchable-pdf'); - populateLanguageList(); + populateLanguageList(); - if (backBtn) { - backBtn.addEventListener('click', function () { - window.location.href = import.meta.env.BASE_URL; - }); - } + 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 = ''; - }); - } - - // Language search - if (langSearch && langList) { - langSearch.addEventListener('input', function () { - const searchTerm = langSearch.value.toLowerCase(); - langList.querySelectorAll('label').forEach(function (label) { - (label as HTMLElement).style.display = label.textContent?.toLowerCase().includes(searchTerm) ? '' : 'none'; - }); - }); - - langList.addEventListener('change', function () { - const selected = Array.from( - langList.querySelectorAll('.lang-checkbox:checked') - ).map(function (cb) { - return tesseractLanguages[(cb as HTMLInputElement).value as keyof typeof tesseractLanguages]; - }); - - if (selectedLangsDisplay) { - selectedLangsDisplay.textContent = selected.length > 0 ? selected.join(', ') : 'None'; - } - - if (processBtn) { - processBtn.disabled = selected.length === 0; - } - }); - } - - // Whitelist preset - if (presetSelect && whitelistInput) { - presetSelect.addEventListener('change', function () { - const preset = presetSelect.value; - if (preset && preset !== 'custom') { - whitelistInput.value = whitelistPresets[preset] || ''; - whitelistInput.disabled = true; - } else { - whitelistInput.disabled = false; - if (preset === '') { - whitelistInput.value = ''; - } - } - }); - } - - // Details toggle - document.querySelectorAll('details').forEach(function (details) { - details.addEventListener('toggle', function () { - const icon = details.querySelector('.details-icon') as HTMLElement; - if (icon) { - icon.style.transform = (details as HTMLDetailsElement).open ? 'rotate(180deg)' : 'rotate(0deg)'; - } - }); + if (fileInput && dropZone) { + fileInput.addEventListener('change', function (e) { + handleFileSelect((e.target as HTMLInputElement).files); }); - // Process button - if (processBtn) { - processBtn.addEventListener('click', runOCR); - } + dropZone.addEventListener('dragover', function (e) { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); - // Copy button - if (copyBtn) { - copyBtn.addEventListener('click', function () { - const textOutput = document.getElementById('ocr-text-output') as HTMLTextAreaElement; - if (textOutput) { - navigator.clipboard.writeText(textOutput.value).then(function () { - copyBtn.innerHTML = ''; - createIcons({ icons }); + dropZone.addEventListener('dragleave', function (e) { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + }); - setTimeout(function () { - copyBtn.innerHTML = ''; - createIcons({ icons }); - }, 2000); - }); - } + 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); + } + } + }); - // Download txt - if (downloadTxtBtn) { - downloadTxtBtn.addEventListener('click', function () { - const textOutput = document.getElementById('ocr-text-output') as HTMLTextAreaElement; - if (textOutput) { - const blob = new Blob([textOutput.value], { type: 'text/plain' }); - downloadFile(blob, 'ocr-text.txt'); - } - }); - } + fileInput.addEventListener('click', function () { + fileInput.value = ''; + }); + } - // Download PDF - if (downloadPdfBtn) { - downloadPdfBtn.addEventListener('click', function () { - if (pageState.searchablePdfBytes) { - downloadFile( - new Blob([new Uint8Array(pageState.searchablePdfBytes)], { type: 'application/pdf' }), - 'searchable.pdf' - ); - } + // Language search + if (langSearch && langList) { + langSearch.addEventListener('input', function () { + const searchTerm = langSearch.value.toLowerCase(); + langList.querySelectorAll('label').forEach(function (label) { + (label as HTMLElement).style.display = label.textContent + ?.toLowerCase() + .includes(searchTerm) + ? '' + : 'none'; + }); + }); + + langList.addEventListener('change', function () { + const selected = Array.from( + langList.querySelectorAll('.lang-checkbox:checked') + ).map(function (cb) { + return tesseractLanguages[ + (cb as HTMLInputElement).value as keyof typeof tesseractLanguages + ]; + }); + + if (selectedLangsDisplay) { + selectedLangsDisplay.textContent = + selected.length > 0 ? selected.join(', ') : 'None'; + } + + if (processBtn) { + processBtn.disabled = selected.length === 0; + } + }); + } + + // Whitelist preset + if (presetSelect && whitelistInput) { + presetSelect.addEventListener('change', function () { + const preset = presetSelect.value; + if (preset && preset !== 'custom') { + whitelistInput.value = whitelistPresets[preset] || ''; + whitelistInput.disabled = true; + } else { + whitelistInput.disabled = false; + if (preset === '') { + whitelistInput.value = ''; + } + } + }); + } + + // Details toggle + document.querySelectorAll('details').forEach(function (details) { + details.addEventListener('toggle', function () { + const icon = details.querySelector('.details-icon') as HTMLElement; + if (icon) { + icon.style.transform = (details as HTMLDetailsElement).open + ? 'rotate(180deg)' + : 'rotate(0deg)'; + } + }); + }); + + // Process button + if (processBtn) { + processBtn.addEventListener('click', runOCR); + } + + // Copy button + if (copyBtn) { + copyBtn.addEventListener('click', function () { + const textOutput = document.getElementById( + 'ocr-text-output' + ) as HTMLTextAreaElement; + if (textOutput) { + navigator.clipboard.writeText(textOutput.value).then(function () { + copyBtn.innerHTML = + ''; + createIcons({ icons }); + + setTimeout(function () { + copyBtn.innerHTML = + ''; + createIcons({ icons }); + }, 2000); }); - } + } + }); + } + + // Download txt + if (downloadTxtBtn) { + downloadTxtBtn.addEventListener('click', function () { + const textOutput = document.getElementById( + 'ocr-text-output' + ) as HTMLTextAreaElement; + if (textOutput) { + const blob = new Blob([textOutput.value], { type: 'text/plain' }); + downloadFile(blob, 'ocr-text.txt'); + } + }); + } + + // Download PDF + if (downloadPdfBtn) { + downloadPdfBtn.addEventListener('click', function () { + if (pageState.searchablePdfBytes) { + downloadFile( + new Blob([new Uint8Array(pageState.searchablePdfBytes)], { + type: 'application/pdf', + }), + 'searchable.pdf' + ); + } + }); + } }); diff --git a/src/js/types/ocr-pdf-type.ts b/src/js/types/ocr-pdf-type.ts index b1ef7e4..00a340d 100644 --- a/src/js/types/ocr-pdf-type.ts +++ b/src/js/types/ocr-pdf-type.ts @@ -1,10 +1,46 @@ export interface OcrWord { - text: string; - bbox: { x0: number; y0: number; x1: number; y1: number }; - confidence: number; + text: string; + bbox: { x0: number; y0: number; x1: number; y1: number }; + confidence: number; } export interface OcrState { - file: File | null; - searchablePdfBytes: Uint8Array | null; + file: File | null; + searchablePdfBytes: Uint8Array | null; +} + +export interface BBox { + x0: number; // left + y0: number; // top (in hOCR coordinate system, origin at top-left) + x1: number; // right + y1: number; // bottom +} + +export interface Baseline { + slope: number; + intercept: number; +} + +export interface OcrLine { + bbox: BBox; + baseline: Baseline; + textangle: number; + words: OcrWord[]; + direction: 'ltr' | 'rtl'; + injectWordBreaks: boolean; +} + +export interface OcrPage { + width: number; + height: number; + dpi: number; + lines: OcrLine[]; +} + +export interface WordTransform { + x: number; + y: number; + fontSize: number; + horizontalScale: number; + rotation: number; } diff --git a/src/js/utils/hocr-transform.ts b/src/js/utils/hocr-transform.ts new file mode 100644 index 0000000..eba2772 --- /dev/null +++ b/src/js/utils/hocr-transform.ts @@ -0,0 +1,266 @@ +import { + BBox, + OcrLine, + OcrPage, + OcrWord, + WordTransform, + Baseline, +} from '@/types'; + +const BBOX_PATTERN = /bbox\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/; +const BASELINE_PATTERN = /baseline\s+([-+]?\d*\.?\d*)\s+([-+]?\d+)/; +const TEXTANGLE_PATTERN = /textangle\s+([-+]?\d*\.?\d*)/; + +export function parseBBox(title: string): BBox | null { + const match = title.match(BBOX_PATTERN); + if (!match) return null; + + return { + x0: parseInt(match[1], 10), + y0: parseInt(match[2], 10), + x1: parseInt(match[3], 10), + y1: parseInt(match[4], 10), + }; +} + +export function parseBaseline(title: string): Baseline { + const match = title.match(BASELINE_PATTERN); + if (!match) { + return { slope: 0, intercept: 0 }; + } + + return { + slope: parseFloat(match[1]) || 0, + intercept: parseInt(match[2], 10) || 0, + }; +} + +export function parseTextangle(title: string): number { + const match = title.match(TEXTANGLE_PATTERN); + if (!match) return 0; + return parseFloat(match[1]) || 0; +} + +export function getTextDirection(element: Element): 'ltr' | 'rtl' { + const dir = element.getAttribute('dir'); + return dir === 'rtl' ? 'rtl' : 'ltr'; +} + +export function shouldInjectWordBreaks(element: Element): boolean { + const lang = element.getAttribute('lang') || ''; + const cjkLangs = ['chi_sim', 'chi_tra', 'jpn', 'kor', 'zh', 'ja', 'ko']; + return !cjkLangs.includes(lang); +} + +export function normalizeText(text: string): string { + return text.normalize('NFKC'); +} + +export function parseHocrDocument(hocrText: string): OcrPage { + const parser = new DOMParser(); + const doc = parser.parseFromString(hocrText, 'text/html'); + + let width = 0; + let height = 0; + const pageDiv = doc.querySelector('.ocr_page'); + if (pageDiv) { + const title = pageDiv.getAttribute('title') || ''; + const bbox = parseBBox(title); + if (bbox) { + width = bbox.x1 - bbox.x0; + height = bbox.y1 - bbox.y0; + } + } + + const lines: OcrLine[] = []; + + const lineClasses = [ + 'ocr_line', + 'ocr_textfloat', + 'ocr_header', + 'ocr_caption', + ]; + const lineSelectors = lineClasses.map((c) => `.${c}`).join(', '); + const lineElements = doc.querySelectorAll(lineSelectors); + + if (lineElements.length > 0) { + lineElements.forEach((lineEl) => { + const line = parseHocrLine(lineEl); + if (line && line.words.length > 0) { + lines.push(line); + } + }); + } else { + const wordElements = doc.querySelectorAll('.ocrx_word'); + if (wordElements.length > 0) { + const words = parseWordsFromElements(wordElements); + if (words.length > 0) { + const allBBox = calculateBoundingBox(words.map((w) => w.bbox)); + lines.push({ + bbox: allBBox, + baseline: { slope: 0, intercept: 0 }, + textangle: 0, + words, + direction: 'ltr', + injectWordBreaks: true, + }); + } + } + } + + return { width, height, dpi: 72, lines }; +} + +function parseHocrLine(lineElement: Element): OcrLine | null { + const title = lineElement.getAttribute('title') || ''; + const bbox = parseBBox(title); + + if (!bbox) return null; + + const baseline = parseBaseline(title); + const textangle = parseTextangle(title); + + const parent = lineElement.closest('.ocr_par') || lineElement.parentElement; + const direction = parent ? getTextDirection(parent) : 'ltr'; + const injectWordBreaks = parent ? shouldInjectWordBreaks(parent) : true; + const wordElements = lineElement.querySelectorAll('.ocrx_word'); + const words = parseWordsFromElements(wordElements); + + return { + bbox, + baseline, + textangle, + words, + direction, + injectWordBreaks, + }; +} + +function parseWordsFromElements(wordElements: NodeListOf): OcrWord[] { + const words: OcrWord[] = []; + + wordElements.forEach((wordEl) => { + const title = wordEl.getAttribute('title') || ''; + const text = normalizeText((wordEl.textContent || '').trim()); + + if (!text) return; + + const bbox = parseBBox(title); + if (!bbox) return; + + const confMatch = title.match(/x_wconf\s+(\d+)/); + const confidence = confMatch ? parseInt(confMatch[1], 10) : 0; + + words.push({ + text, + bbox, + confidence, + }); + }); + + return words; +} + +function calculateBoundingBox(bboxes: BBox[]): BBox { + if (bboxes.length === 0) { + return { x0: 0, y0: 0, x1: 0, y1: 0 }; + } + + return { + x0: Math.min(...bboxes.map((b) => b.x0)), + y0: Math.min(...bboxes.map((b) => b.y0)), + x1: Math.max(...bboxes.map((b) => b.x1)), + y1: Math.max(...bboxes.map((b) => b.y1)), + }; +} + +/** + * Calculate the transformation parameters for drawing a word + * + * pdf-lib doesn't support horizontal text scaling (Tz operator), + * we calculate a font size that makes the text width exactly match the word bbox width. + * + * @param word - The word to position + * @param line - The line containing this word + * @param pageHeight - Height of the page in pixels (for coordinate flip) + * @param fontWidthFn - Function to calculate text width at a given font size + * @returns Transform parameters for pdf-lib + */ +export function calculateWordTransform( + word: OcrWord, + line: OcrLine, + pageHeight: number, + fontWidthFn: (text: string, fontSize: number) => number +): WordTransform { + const wordBBox = word.bbox; + const wordWidth = wordBBox.x1 - wordBBox.x0; + const wordHeight = wordBBox.y1 - wordBBox.y0; + + let fontSize = wordHeight; + const maxIterations = 10; + + for (let i = 0; i < maxIterations; i++) { + const currentWidth = fontWidthFn(word.text, fontSize); + if (currentWidth <= 0) break; + + const ratio = wordWidth / currentWidth; + const newFontSize = fontSize * ratio; + + if (Math.abs(newFontSize - fontSize) / fontSize < 0.01) { + fontSize = newFontSize; + break; + } + fontSize = newFontSize; + } + + fontSize = Math.max(1, Math.min(fontSize, wordHeight * 2)); + + const fontWidth = fontWidthFn(word.text, fontSize); + const horizontalScale = fontWidth > 0 ? wordWidth / fontWidth : 1; + + const slopeAngle = Math.atan(line.baseline.slope) * (180 / Math.PI); + const rotation = -line.textangle + slopeAngle; + + const x = wordBBox.x0; + + // pdf-lib draws text from baseline, so we position at word bottom + const y = pageHeight - wordBBox.y1; + + return { + x, + y, + fontSize, + horizontalScale, + rotation, + }; +} + +export function calculateSpaceTransform( + prevWord: OcrWord, + nextWord: OcrWord, + line: OcrLine, + pageHeight: number, + spaceWidthFn: (fontSize: number) => number +): { x: number; y: number; horizontalScale: number; fontSize: number } | null { + const lineHeight = line.bbox.y1 - line.bbox.y0; + const fontSize = Math.max(lineHeight + line.baseline.intercept, 1); + + const gapStart = prevWord.bbox.x1; + const gapEnd = nextWord.bbox.x0; + const gapWidth = gapEnd - gapStart; + + if (gapWidth <= 0) return null; + + const spaceWidth = spaceWidthFn(fontSize); + if (spaceWidth <= 0) return null; + + const horizontalScale = gapWidth / spaceWidth; + const baselineY = pageHeight - line.bbox.y1 - line.baseline.intercept; + + return { + x: gapStart, + y: baselineY, + horizontalScale, + fontSize, + }; +} From 1ac0f751e80ba26763f0fbf5609584ef8ba6f5c8 Mon Sep 17 00:00:00 2001 From: NightFeather Date: Sun, 11 Jan 2026 03:19:45 +0800 Subject: [PATCH 16/73] Add Traditional Chinese (`zh-TW`) localization --- TRANSLATION.md | 4 + nginx.conf | 2 +- public/locales/zh-TW/common.json | 318 +++++++++++++++++++++++++++++++ public/locales/zh-TW/tools.json | 282 +++++++++++++++++++++++++++ src/js/i18n/i18n.ts | 12 +- vite.config.ts | 2 +- 6 files changed, 613 insertions(+), 7 deletions(-) create mode 100644 public/locales/zh-TW/common.json create mode 100644 public/locales/zh-TW/tools.json diff --git a/TRANSLATION.md b/TRANSLATION.md index 57dc499..429f4ec 100644 --- a/TRANSLATION.md +++ b/TRANSLATION.md @@ -23,6 +23,8 @@ BentoPDF uses **i18next** for internationalization (i18n). Currently supported l - **German** (`de`) - **Vietnamese** (`vi`) - **Indonesian** (`id`) +- **Chinese** (`zh`) +- **Traditional Chinese (Taiwan)** (`zh-TW`) The app automatically detects the language from the URL path: @@ -30,6 +32,8 @@ The app automatically detects the language from the URL path: - `/de/` → German - `/vi/` → Vietnamese - `/id/` → Indonesian +- `/zh/` → Chinese +- `/zh-TW/` → Traditional Chinese (Taiwan) --- diff --git a/nginx.conf b/nginx.conf index a4904f5..1d3f884 100644 --- a/nginx.conf +++ b/nginx.conf @@ -26,7 +26,7 @@ http { root /usr/share/nginx/html; index index.html; - rewrite ^/(en|de|zh|vi|it|tr|id)/(.*)$ /$2 last; + rewrite ^/(en|de|zh|zh-TW|vi|it|tr|id)/(.*)$ /$2 last; location ~* \.html$ { expires 1h; diff --git a/public/locales/zh-TW/common.json b/public/locales/zh-TW/common.json new file mode 100644 index 0000000..cc7261a --- /dev/null +++ b/public/locales/zh-TW/common.json @@ -0,0 +1,318 @@ +{ + "nav": { + "home": "首頁", + "about": "關於我們", + "contact": "聯絡我們", + "licensing": "產品授權", + "allTools": "所有工具", + "openMainMenu": "開啟主選單", + "language": "語言" + }, + "hero": { + "title": "專為隱私打造的", + "pdfToolkit": "PDF 工具箱", + "builtForPrivacy": " ", + "noSignups": "不須註冊", + "unlimitedUse": "無限使用", + "worksOffline": "離線可用", + "startUsing": "立刻開始使用" + }, + "usedBy": { + "title": "被下列公司及其員工採用" + }, + "features": { + "title": "為何你該選擇", + "bentoPdf": "BentoPDF?", + "noSignup": { + "title": "不須註冊", + "description": "立即可用,不須帳號或電子郵件。" + }, + "noUploads": { + "title": "不須上傳", + "description": "所有文件都在用戶端處理,永遠不會離開你的裝置。" + }, + "foreverFree": { + "title": "永遠免費", + "description": "所有工具免費使用,沒有試用期,也沒有付費牆。" + }, + "noLimits": { + "title": "沒有限制", + "description": "隨心所欲的使用,沒有任何隱藏限制。" + }, + "batchProcessing": { + "title": "批量處理", + "description": "一次處理無限量的 PDF 檔案。" + }, + "lightningFast": { + "title": "快如閃電", + "description": "瞬間處理 PDF,無須忍受任何等待或延遲。" + } + }, + "tools": { + "title": "開始使用", + "toolsLabel": "工具", + "subtitle": "點擊任意工具以開始上傳檔案", + "searchPlaceholder": "搜尋工具(例如「合併」或「分割」...)", + "backToTools": "返回工具列表" + }, + "upload": { + "clickToSelect": "點擊以選擇檔案", + "orDragAndDrop": "或將檔案拖放到此處", + "pdfOrImages": "PDF 或圖片", + "filesNeverLeave": "你的檔案永遠不會離開你的裝置。", + "addMore": "添加更多檔案", + "clearAll": "清除全部" + }, + "loader": { + "processing": "正在處理..." + }, + "alert": { + "title": "提示", + "ok": "確認" + }, + "preview": { + "title": "文件預覽", + "downloadAsPdf": "下載為 PDF", + "close": "關閉" + }, + "settings": { + "title": "設定", + "shortcuts": "快捷鍵", + "preferences": "偏好設定", + "displayPreferences": "顯示設定", + "searchShortcuts": "搜尋快捷鍵...", + "shortcutsInfo": "按下並按住按鍵以設定快捷鍵。變更將自動儲存。", + "shortcutsWarning": "⚠️ 避免使用瀏覽器常用快捷鍵(Cmd/Ctrl+W、Cmd/Ctrl+T、Cmd/Ctrl+N 等),它們可能無法穩定運作。", + "import": "匯入", + "export": "匯出", + "resetToDefaults": "恢復預設值", + "fullWidthMode": "全寬模式", + "fullWidthDescription": "使用全螢幕寬度而非置中容器顯示所有工具", + "settingsAutoSaved": "設定會自動儲存", + "clickToSet": "點擊以設定", + "pressKeys": "按下按鍵...", + "warnings": { + "alreadyInUse": "快捷鍵已被占用", + "assignedTo": "已被指定為:", + "chooseDifferent": "請選擇一個不同的快捷鍵。", + "reserved": "保留快捷鍵警告", + "commonlyUsed": "常被用於:", + "unreliable": "這個快捷鍵可能與系統/瀏覽器行為衝突或無法穩定運作。", + "useAnyway": "仍要使用嗎?", + "resetTitle": "重設快捷鍵", + "resetMessage": "確定要將所有快捷鍵恢復為預設值嗎?

這個操作無法被撤回。", + "importSuccessTitle": "匯入成功", + "importSuccessMessage": "快捷鍵匯入成功!", + "importFailTitle": "匯入失敗", + "importFailMessage": "匯入快捷鍵失敗。無效的檔案格式。" + } + }, + "warning": { + "title": "警告", + "cancel": "取消", + "proceed": "繼續" + }, + "compliance": { + "title": "你的資料永遠不會離開你的裝置", + "weKeep": "我們確保", + "yourInfoSafe": "你的資訊安全", + "byFollowingStandards": "遵循全球安全標準。", + "processingLocal": "所有處理過程都在你的裝置上進行。", + "gdpr": { + "title": "符合 GDPR 規範", + "description": "保護歐盟境內個人的數據及隱私。" + }, + "ccpa": { + "title": "符合 CCPA 規範", + "description": "賦予加州居民對其個人資訊如何被蒐集、使用及分享的權利。" + }, + "hipaa": { + "title": "符合 HIPAA 規範", + "description": "制定處理美國健保系統中敏感健康資訊的規範。" + } + }, + "faq": { + "title": "常見", + "questions": "問題", + "isFree": { + "question": "BentoPDF 真的是免費的嗎?", + "answer": "沒錯,完全免費。BentoPDF 上的所有工具均為 100% 免費使用,並且沒有檔案限制、無須註冊且無浮水印。我們相信每個人都值得免費使用簡單且強大的 PDF 工具。" + }, + "areFilesSecure": { + "question": "我的檔案都是安全的嗎?它們都在哪裡被處理?", + "answer": "你的檔案都非常安全,因為它們從未離開你的電腦。所有處理過程都直接在你的網頁瀏覽器中進行(用戶端)。我們永遠不會將你的檔案上傳到伺服器,因此你對你的文件保有完全的隱私與控制權。" + }, + "platforms": { + "question": "我能在 Mac、Windows 和行動裝置上使用嗎?", + "answer": "可以!由於 BentoPDF 完全在你的瀏覽器中運作,它在任何有著現代網頁瀏覽器的系統中都能運行,包含 Windows、macOS、Linux、iOS 和 Android。" + }, + "gdprCompliant": { + "question": "BentoPDF 符合 GDPR 規範嗎?", + "answer": "是的。BentoPDF 完全符合 GDPR 規範。由於所有檔案處理都在你的瀏覽器本地發生且我們永不蒐集或傳輸你的檔案至任何伺服器,我們無法存取你的資料。這確保你的文件永遠都在你的控制之中。" + }, + "dataStorage": { + "question": "你會保存或追蹤我的檔案嗎?", + "answer": "不。我們永不儲存、追蹤或記錄你的檔案。你在 BentoPDF 上進行的任何操作都發生在你的瀏覽器記憶體中,並且會在你關閉頁面後立即消失。沒有上傳、沒有歷史紀錄且無伺服器參與。" + }, + "different": { + "question": "BentoPDF 跟其他的 PDF 工具有何不同之處?", + "answer": "大多數 PDF 工具都透過將你的檔案上傳至伺服器好進行處理。BentoPDF 永遠不會那麼做。我們使用安全且現代的網頁科技以在你的瀏覽器中直接處理檔案。這意味著更快的性能、更強的隱私與完全的安心。" + }, + "browserBased": { + "question": "瀏覽器端處理如何保障我的安全?", + "answer": "透過完全在你的瀏覽器內運作,BentoPDF 確保你的文件從未離開你的裝置。這消除了伺服器遭駭、資料外洩與未授權訪問的風險。你的檔案永遠都屬於你。" + }, + "analytics": { + "question": "你會使用 Cookies 或網站分析來追蹤我嗎?", + "answer": "我們在乎你的隱私。BentoPDF 並不追蹤個人資訊。我們僅使用 Simple Analytics 來查看匿名訪問次數。這代表我們能知道有多少使用者造訪過我們的網站,但我們永遠都不會知道你是誰。Simple Analytics 完全符合 GDPR 規範且尊重你的隱私。" + } + }, + "testimonials": { + "title": "看看我們的", + "users": "使用者", + "say": "怎麼說" + }, + "support": { + "title": "喜歡我的作品嗎?", + "description": "BentoPDF 是一個出於熱情開發的專案,旨在為每個人提供一個免費、注重隱私且強大的 PDF 工具組。如果有幫上你的忙,請考慮支持它的開發。每杯咖啡都意義重大!", + "buyMeCoffee": "買杯咖啡給我" + }, + "footer": { + "copyright": "© 2025 BentoPDF。版權所有。", + "version": "版本", + "company": "公司", + "aboutUs": "關於我們", + "faqLink": "常見問題", + "contactUs": "聯絡我們", + "legal": "法律", + "termsAndConditions": "服務條款", + "privacyPolicy": "隱私政策", + "followUs": "關注我們" + }, + "merge": { + "title": "合併 PDF", + "description": "合併整個檔案,或選擇特定頁面合併為新文件。", + "fileMode": "檔案模式", + "pageMode": "頁面模式", + "howItWorks": "使用說明:", + "fileModeInstructions": [ + "點擊並抓取圖標來改變檔案順序。", + "在每個文件的「頁碼」框中,你可以僅指定想要合併的頁面範圍(例如「1-3, 5」)。", + "將「頁碼」框留空以包含該檔案的所有頁面。" + ], + "pageModeInstructions": [ + "下列是你上傳的 PDF 中的所有頁面。", + "只要將個別頁面縮圖拖放到指定位置,即可為新檔案建立您想要的精確排序。" + ], + "mergePdfs": "合併 PDF" + }, + "common": { + "page": "頁", + "pages": "頁", + "of": " / ", + "download": "下載", + "cancel": "取消", + "save": "儲存", + "delete": "刪除", + "edit": "編輯", + "add": "添加", + "remove": "移除", + "loading": "載入中...", + "error": "錯誤", + "success": "成功", + "file": "檔案", + "files": "檔案" + }, + "about": { + "hero": { + "title": "我們相信 PDF 工具應該", + "subtitle": "快速、私密且免費。", + "noCompromises": "絕不妥協。" + }, + "mission": { + "title": "我們的任務", + "description": "在尊重你的隱私且從不要求收費的同時提供最全面的 PDF 工具箱。我們相信核心文件工具應讓任何人隨時隨地不受限的使用。" + }, + "philosophy": { + "label": "我們的核心理念", + "title": "永遠以隱私為重。", + "description": "在數據被商品化的時代,我們採取截然不同的做法。所有 BentoPDF 工具的處理流程皆在你的瀏覽器本地完成。這意味著你的檔案絕不觸及我們的伺服器,我們從未看見你的文件內容,更不會追蹤你的行為。你的文件將始終保持無可置疑的私密性。這不僅是功能,更是我們的立身之本。" + }, + "whyBentopdf": { + "title": "為何選擇", + "speed": { + "title": "生來迅捷", + "description": "無需等待與伺服器間的上傳和下載。透過在你的瀏覽器中使用 WebAssembly 等現代網路科技處理檔案,我們得以為所有工具提供無與倫比的速度。" + }, + "free": { + "title": "完全免費", + "description": "沒有試用期、訂閱、隱藏費用與所謂的「高級」功能。我們相信強大的 PDF 工具應該是一種公共設施,而非以營利為重。" + }, + "noAccount": { + "title": "無須帳號", + "description": "立即開始使用任何工具。我們不需要你的電子郵件、密碼或任何個人資訊。你的工作流程應當匿名且不受阻礙。" + }, + "openSource": { + "title": "開源精神", + "description": "將透明性視為核心打造。我們使用了如 PDF-lib 和 PDF.js 等優秀的開源庫,並且相信社群驅動力能讓強大的工具惠及每一個人。" + } + }, + "cta": { + "title": "準備好開始了嗎?", + "description": "加入成千上萬信任 BentoPDF 能勝任他們日常文件需求的使用者們。體驗隱私與性能所帶來的差距。", + "button": "探索所有工具" + } + }, + "contact": { + "title": "保持聯絡", + "subtitle": "我們很樂意收到你的訊息。無論你想提出的是問題、回饋或功能請求,都請隨時聯繫我們。", + "email": "你可以直接透過電子郵件聯繫我們:" + }, + "licensing": { + "title": "授權使用", + "subtitle": "選擇適合需求的產品授權。" + }, + "multiTool": { + "uploadPdfs": "上傳 PDF", + "upload": "上傳", + "addBlankPage": "添加空白頁面", + "edit": "編輯:", + "undo": "復原", + "redo": "取消復原", + "reset": "重設", + "selection": "選取:", + "selectAll": "選取全部", + "deselectAll": "取消選取全部", + "rotate": "旋轉:", + "rotateLeft": "左", + "rotateRight": "右", + "transform": "變換:", + "duplicate": "複製", + "split": "分割", + "clear": "清除:", + "delete": "刪除", + "download": "下載:", + "downloadSelected": "下載選取的項目", + "exportPdf": "匯出 PDF", + "uploadPdfFiles": "選擇 PDF 檔案", + "dragAndDrop": "拖放 PDF 檔案至此處,或是點擊以選取", + "selectFiles": "選擇檔案", + "renderingPages": "渲染頁面...", + "actions": { + "duplicatePage": "複製此頁", + "deletePage": "刪除此頁", + "insertPdf": "在此頁後插入 PDF", + "toggleSplit": "在此頁後切換分割" + }, + "pleaseWait": "請稍後", + "pagesRendering": "正在渲染頁面。請稍後...", + "noPagesSelected": "未選擇頁面", + "selectOnePage": "請至少選擇一頁以開始下載。", + "noPages": "無頁面", + "noPagesToExport": "無可匯出的頁面。", + "renderingTitle": "正在渲染頁面預覽", + "errorRendering": "無法渲染頁面縮圖", + "error": "錯誤", + "failedToLoad": "載入失敗" + } +} \ No newline at end of file diff --git a/public/locales/zh-TW/tools.json b/public/locales/zh-TW/tools.json new file mode 100644 index 0000000..1a296ed --- /dev/null +++ b/public/locales/zh-TW/tools.json @@ -0,0 +1,282 @@ +{ + "categories": { + "popularTools": "熱門工具", + "editAnnotate": "編輯與註解", + "convertToPdf": "轉換為 PDF", + "convertFromPdf": "從 PDF 轉換", + "organizeManage": "組織與管理", + "optimizeRepair": "優化與修復", + "securePdf": "安全 PDF" + }, + "pdfMultiTool": { + "name": "PDF 多功能工具", + "subtitle": "在統一的頁面中合併、分割、組織、刪除、旋轉、添加空白頁面、提取與複製。" + }, + "mergePdf": { + "name": "合併 PDF", + "subtitle": "將多個 PDF 合併為一個檔案。保留書籤。" + }, + "splitPdf": { + "name": "分割 PDF", + "subtitle": "將指定範圍的頁面提取為新的 PDF。" + }, + "compressPdf": { + "name": "壓縮 PDF", + "subtitle": "降低你的 PDF 檔案大小。" + }, + "pdfEditor": { + "name": "PDF 編輯器", + "subtitle": "註解、螢光、塗黑、評論、添加圖形或圖片、搜尋與查看 PDF。" + }, + "jpgToPdf": { + "name": "JPG 轉 PDF", + "subtitle": "從一張或多張 JPG 圖片建立 PDF。" + }, + "signPdf": { + "name": "簽署 PDF", + "subtitle": "繪製、輸入或上傳你的簽名。" + }, + "cropPdf": { + "name": "裁切 PDF", + "subtitle": "修剪你的 PDF 中所有頁面的邊界。" + }, + "extractPages": { + "name": "提取頁面", + "subtitle": "將選取的頁面保存為新的檔案。" + }, + "duplicateOrganize": { + "name": "複製與組織", + "subtitle": "複製、重新排序與刪除頁面。" + }, + "deletePages": { + "name": "刪除頁面", + "subtitle": "移除你的文件中的特定頁面。" + }, + "editBookmarks": { + "name": "編輯書籤", + "subtitle": "添加、編輯、匯入、刪除與提取 PDF 書籤。" + }, + "tableOfContents": { + "name": "目錄", + "subtitle": "從 PDF 書籤生成目錄頁。" + }, + "pageNumbers": { + "name": "頁碼", + "subtitle": "在你的文件中插入頁碼。" + }, + "addWatermark": { + "name": "添加浮水印", + "subtitle": "在你的 PDF 頁面上壓印文字或圖片。" + }, + "headerFooter": { + "name": "頁首與頁尾", + "subtitle": "在頁面的頂部與底部新增文字。" + }, + "invertColors": { + "name": "反轉顏色", + "subtitle": "為你的 PDF 建立深色版本。" + }, + "backgroundColor": { + "name": "背景顏色", + "subtitle": "更改你的 PDF 的背景顏色。" + }, + "changeTextColor": { + "name": "更改文字顏色", + "subtitle": "更改你的 PDF 中的文字顏色。" + }, + "addStamps": { + "name": "添加印章", + "subtitle": "使用註解工具列在你的 PDF 中添加圖片印章。", + "usernameLabel": "印章使用者名稱", + "usernamePlaceholder": "輸入你的名稱(印章用)", + "usernameHint": "該名稱會出現在你建立的印章上。" + }, + "removeAnnotations": { + "name": "移除註解", + "subtitle": "去除留言、螢光與連結。" + }, + "pdfFormFiller": { + "name": "PDF 表單填寫器", + "subtitle": "直接在你的瀏覽器中填寫表單。支援 XFA 表單。" + }, + "createPdfForm": { + "name": "建立 PDF 表單", + "subtitle": "透過拖放文字框建立可填寫的 PDF 表單。" + }, + "removeBlankPages": { + "name": "移除空白頁面", + "subtitle": "自動偵測並刪除空白頁面。" + }, + "imageToPdf": { + "name": "圖片轉 PDF", + "subtitle": "將 JPG、PNG、WebP、BMP、TIFF、SVG 與 HEIC 轉換為 PDF。" + }, + "pngToPdf": { + "name": "PNG 轉 PDF", + "subtitle": "從一張或多張 PNG 圖片建立 PDF。" + }, + "webpToPdf": { + "name": "WebP 轉 PDF", + "subtitle": "從一張或多張 WebP 圖片建立 PDF。" + }, + "svgToPdf": { + "name": "SVG 轉 PDF", + "subtitle": "從一張或多張 SVG 圖片建立 PDF。" + }, + "bmpToPdf": { + "name": "BMP 轉 PDF", + "subtitle": "從一張或多張 BMP 圖片建立 PDF。" + }, + "heicToPdf": { + "name": "HEIC 轉 PDF", + "subtitle": "從一張或多張 HEIC 圖片建立 PDF。" + }, + "tiffToPdf": { + "name": "TIFF 轉 PDF", + "subtitle": "從一張或多張 TIFF 圖片建立 PDF。" + }, + "textToPdf": { + "name": "Text 轉 PDF", + "subtitle": "將純文字檔案轉換為 PDF。" + }, + "jsonToPdf": { + "name": "JSON 轉 PDF", + "subtitle": "將 JSON 檔案轉換為 PDF 格式。" + }, + "pdfToJpg": { + "name": "PDF 轉 JPG", + "subtitle": "將每個 PDF 頁面轉換為 JPG 圖片。" + }, + "pdfToPng": { + "name": "PDF 轉 PNG", + "subtitle": "將每個 PDF 頁面轉換為 PNG 圖片。" + }, + "pdfToWebp": { + "name": "PDF 轉 WebP", + "subtitle": "將每個 PDF 頁面轉換為 WebP 圖片。" + }, + "pdfToBmp": { + "name": "PDF 轉 BMP", + "subtitle": "將每個 PDF 頁面轉換為 BMP 圖片。" + }, + "pdfToTiff": { + "name": "PDF 轉 TIFF", + "subtitle": "將每個 PDF 頁面轉換為 TIFF 圖片。" + }, + "pdfToGreyscale": { + "name": "PDF 轉灰階", + "subtitle": "將所有顏色轉換為黑白。" + }, + "pdfToJson": { + "name": "PDF 轉 JSON", + "subtitle": "將 PDF 檔案轉換為 JSON 格式。" + }, + "ocrPdf": { + "name": "OCR PDF", + "subtitle": "使 PDF 可搜尋且可複製。" + }, + "alternateMix": { + "name": "交錯混合頁面", + "subtitle": "將每個 PDF 的頁面交錯合併。保留書籤。" + }, + "addAttachments": { + "name": "添加附件", + "subtitle": "嵌入一個或多個檔案至你的 PDF 中。" + }, + "extractAttachments": { + "name": "提取附件", + "subtitle": "從 PDF 中提取所有嵌入的檔案為 ZIP。" + }, + "editAttachments": { + "name": "編輯附件", + "subtitle": "查看或移除你的 PDF 中的附件。" + }, + "dividePages": { + "name": "分割頁面", + "subtitle": "垂直或水平分割頁面。" + }, + "addBlankPage": { + "name": "添加空白頁面", + "subtitle": "在你的 PDF 中的任一位置插入空白頁面。" + }, + "reversePages": { + "name": "反轉頁面", + "subtitle": "反轉你的文件中所有頁面的順序。" + }, + "rotatePdf": { + "name": "旋轉 PDF", + "subtitle": "以 90 度增量旋轉頁面。" + }, + "nUpPdf": { + "name": "N-Up PDF", + "subtitle": "將多個頁面排列在單張紙上。" + }, + "combineToSinglePage": { + "name": "合併為單一頁面", + "subtitle": "將所有頁面縫合為一個單一且連續的滾動頁面。" + }, + "viewMetadata": { + "name": "查看元資料", + "subtitle": "檢視你的 PDF 中的隱藏屬性。" + }, + "editMetadata": { + "name": "編輯元資料", + "subtitle": "更改作者、標題和其他屬性。" + }, + "pdfsToZip": { + "name": "PDF 轉 ZIP", + "subtitle": "將多個 PDF 檔案打包為 ZIP 壓縮檔。" + }, + "comparePdfs": { + "name": "比較 PDF", + "subtitle": "並排比較兩個 PDF。" + }, + "posterizePdf": { + "name": "海報化 PDF", + "subtitle": "將大頁面分割為多個較小的頁面。" + }, + "fixPageSize": { + "name": "修復頁面大小", + "subtitle": "將所有頁面標準化為統一尺寸。" + }, + "linearizePdf": { + "name": "線性化 PDF", + "subtitle": "為快速網頁瀏覽優化 PDF。" + }, + "pageDimensions": { + "name": "頁面尺寸", + "subtitle": "分析頁面大小、方向和單位。" + }, + "removeRestrictions": { + "name": "移除限制", + "subtitle": "移除與數位簽名的 PDF 檔案相關的密碼保護與安全限制。" + }, + "repairPdf": { + "name": "修復 PDF", + "subtitle": "從受損的 PDF 檔案中復原資料。" + }, + "encryptPdf": { + "name": "加密 PDF", + "subtitle": "透過添加密碼為你的 PDF 上鎖。" + }, + "sanitizePdf": { + "name": "清理 PDF", + "subtitle": "移除元資料、註解、腳本與其他資料。" + }, + "decryptPdf": { + "name": "解密 PDF", + "subtitle": "透過移除密碼保護解鎖 PDF。" + }, + "flattenPdf": { + "name": "平面化 PDF", + "subtitle": "使表單欄位和註解不可編輯。" + }, + "removeMetadata": { + "name": "移除元資料", + "subtitle": "除去你的 PDF 中的隱藏資料。" + }, + "changePermissions": { + "name": "更改權限", + "subtitle": "設定或變更 PDF 上的使用者權限。" + } +} \ No newline at end of file diff --git a/src/js/i18n/i18n.ts b/src/js/i18n/i18n.ts index ed10464..ea191b8 100644 --- a/src/js/i18n/i18n.ts +++ b/src/js/i18n/i18n.ts @@ -7,6 +7,7 @@ export const supportedLanguages = [ 'en', 'de', 'zh', + 'zh-TW', 'vi', 'tr', 'id', @@ -18,6 +19,7 @@ export const languageNames: Record = { en: 'English', de: 'Deutsch', zh: '中文', + "zh-TW": '繁體中文(台灣)', vi: 'Tiếng Việt', tr: 'Türkçe', id: 'Bahasa Indonesia', @@ -26,7 +28,7 @@ export const languageNames: Record = { export const getLanguageFromUrl = (): SupportedLanguage => { const path = window.location.pathname; - const langMatch = path.match(/^\/(en|de|zh|vi|tr|id|it)(?:\/|$)/); + const langMatch = path.match(/^\/(en|de|zh|zh-TW|vi|tr|id|it)(?:\/|$)/); if ( langMatch && supportedLanguages.includes(langMatch[1] as SupportedLanguage) @@ -88,9 +90,9 @@ export const changeLanguage = (lang: SupportedLanguage): void => { const currentLang = getLanguageFromUrl(); let newPath: string; - if (currentPath.match(/^\/(en|de|zh|vi|tr|id|it)\//)) { - newPath = currentPath.replace(/^\/(en|de|zh|vi|tr|id|it)\//, `/${lang}/`); - } else if (currentPath.match(/^\/(en|de|zh|vi|tr|id|it)$/)) { + if (currentPath.match(/^\/(en|de|zh|zh-TW|vi|tr|id|it)\//)) { + newPath = currentPath.replace(/^\/(en|de|zh|zh-TW|vi|tr|id|it)\//, `/${lang}/`); + } else if (currentPath.match(/^\/(en|de|zh|zh-TW|vi|tr|id|it)$/)) { newPath = `/${lang}`; } else { newPath = `/${lang}${currentPath}`; @@ -154,7 +156,7 @@ export const rewriteLinks = (): void => { return; } - if (href.match(/^\/(en|de|zh|vi|tr|id|it)\//)) { + if (href.match(/^\/(en|de|zh|zh-TW|vi|tr|id|it)\//)) { return; } let newHref: string; diff --git a/vite.config.ts b/vite.config.ts index c4caa12..e6f2cdb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,7 +14,7 @@ function pagesRewritePlugin(): Plugin { server.middlewares.use((req, res, next) => { const url = req.url?.split('?')[0] || ''; - const langMatch = url.match(/^\/(en|de|zh|vi|it|id|tr)(\/.*)?$/); + const langMatch = url.match(/^\/(en|de|zh|zh-TW|vi|it|id|tr)(\/.*)?$/); if (langMatch) { const lang = langMatch[1]; const restOfPath = langMatch[2] || '/'; From 3137b0e2c4aaffbcad1e79cdfc8939bcc460c5c7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 10 Jan 2026 19:30:32 +0000 Subject: [PATCH 17/73] @NightFeather0615 has signed the CLA in alam00000/bentopdf#379 --- signatures/cla.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/signatures/cla.json b/signatures/cla.json index 42dcea1..8327ee1 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -239,6 +239,14 @@ "created_at": "2026-01-08T08:29:28Z", "repoId": 1074785178, "pullRequestNo": 289 + }, + { + "name": "NightFeather0615", + "id": 77222233, + "comment_id": 3733421577, + "created_at": "2026-01-10T19:30:21Z", + "repoId": 1074785178, + "pullRequestNo": 379 } ] } \ No newline at end of file From d7fce5987dca5be7a2f60c99e6968e1ef5abbe29 Mon Sep 17 00:00:00 2001 From: gianlucaalfa Date: Sun, 11 Jan 2026 11:07:13 +0100 Subject: [PATCH 18/73] fix-italian-translation --- public/locales/it/common.json | 180 +++++++++++++++++----------------- public/locales/it/tools.json | 39 ++++++-- 2 files changed, 123 insertions(+), 96 deletions(-) diff --git a/public/locales/it/common.json b/public/locales/it/common.json index c620102..2b72582 100644 --- a/public/locales/it/common.json +++ b/public/locales/it/common.json @@ -1,50 +1,50 @@ { "nav": { "home": "Home", - "about": "Chi Siamo", + "about": "Chi siamo", "contact": "Contatti", "licensing": "Licenze", - "allTools": "Tutti gli Strumenti", - "openMainMenu": "Apri il Menu Principale", + "allTools": "Tutti gli strumenti", + "openMainMenu": "Apri il menu principale", "language": "Lingua" }, "donation": { - "message": "Love BentoPDF? Help us keep it free and open source!", - "button": "Donate" + "message": "Ti piace BentoPDF? Aiutaci a mantenerlo gratuito e open source!", + "button": "Dona" }, "hero": { - "title": "I", - "pdfToolkit": "tuoi attrezzi per i PDF", - "builtForPrivacy": "creati per la privacy", - "noSignups": "Nessun iscrizione", + "title": "Il", + "pdfToolkit": "kit di strumenti PDF", + "builtForPrivacy": "pensato per la privacy", + "noSignups": "Nessuna registrazione", "unlimitedUse": "Uso illimitato", "worksOffline": "Funziona offline", - "startUsing": "Inizia ad usarlo ora" + "startUsing": "Inizia a usarlo ora" }, "usedBy": { "title": "Usato da aziende e persone che lavorano in" }, "features": { - "title": "Perchè scegliere", + "title": "Perché scegliere", "bentoPdf": "BentoPDF?", "noSignup": { - "title": "Nessuna Registrazione", - "description": "Usa subito, senza account o email." + "title": "Nessuna registrazione", + "description": "Inizia subito, senza account né email." }, "noUploads": { "title": "Nessun caricamento", "description": "100% client-side, i tuoi file non lasciano mai il dispositivo." }, "foreverFree": { - "title": "Sempre Gratis", + "title": "Sempre gratis", "description": "Tutti gli strumenti, nessuna prova, nessun paywall." }, "noLimits": { - "title": "Senza Limiti", + "title": "Senza limiti", "description": "Usalo quanto vuoi, senza limiti nascosti." }, "batchProcessing": { - "title": "Elaborazione in Batch", + "title": "Elaborazione in batch", "description": "Gestisci un numero illimitato di PDF in un'unica operazione." }, "lightningFast": { @@ -55,15 +55,15 @@ "tools": { "title": "Inizia con", "toolsLabel": "Strumenti", - "subtitle": "Clicca uno strumento per aprire il caricatore di file", - "searchPlaceholder": "Cerca uno strumento (es. 'split', 'organize'...)", - "backToTools": "Torna agli Strumenti", - "firstLoadNotice": "Il primo caricamento richiede un momento mentre scarichiamo il nostro motore di conversione. Dopo di ciò, tutti i caricamenti saranno immediati." + "subtitle": "Clicca su uno strumento per aprire il caricatore di file", + "searchPlaceholder": "Cerca uno strumento (es. \"split\", \"organizza\"...)", + "backToTools": "Torna agli strumenti", + "firstLoadNotice": "Il primo caricamento richiede qualche istante mentre scarichiamo il motore di conversione. Dopo di ciò, tutti i caricamenti saranno immediati." }, "upload": { "clickToSelect": "Clicca per selezionare un file", - "orDragAndDrop": "o trascina e rilascia", - "pdfOrImages": "PDF o Immagini", + "orDragAndDrop": "oppure trascina e rilascia", + "pdfOrImages": "PDF o immagini", "filesNeverLeave": "I tuoi file non lasciano mai il tuo dispositivo.", "addMore": "Aggiungi altri file", "clearAll": "Svuota tutto" @@ -76,7 +76,7 @@ "ok": "OK" }, "preview": { - "title": "Anteprima Documento", + "title": "Anteprima documento", "downloadAsPdf": "Scarica come PDF", "close": "Chiudi" }, @@ -87,7 +87,7 @@ "displayPreferences": "Preferenze di visualizzazione", "searchShortcuts": "Cerca scorciatoie...", "shortcutsInfo": "Premi e tieni premuti i tasti per impostare una scorciatoia. Le modifiche vengono salvate automaticamente.", - "shortcutsWarning": "⚠️ Evita scorciatoie comuni del browser (Cmd/Ctrl+W, Cmd/Ctrl+T, Cmd/Ctrl+N ecc.) poiché potrebbero non funzionare in modo affidabile.", + "shortcutsWarning": "⚠️ Evita le scorciatoie comuni del browser (Cmd/Ctrl+W, Cmd/Ctrl+T, Cmd/Ctrl+N ecc.) perché potrebbero non funzionare in modo affidabile.", "import": "Importa", "export": "Esporta", "resetToDefaults": "Reimposta ai valori predefiniti", @@ -95,7 +95,7 @@ "fullWidthDescription": "Usa l'intera larghezza dello schermo per tutti gli strumenti invece di un contenitore centrato", "settingsAutoSaved": "Le impostazioni vengono salvate automaticamente", "clickToSet": "Clicca per impostare", - "pressKeys": "Premi tasti...", + "pressKeys": "Premi i tasti...", "warnings": { "alreadyInUse": "Scorciatoia già in uso", "assignedTo": "è già assegnata a:", @@ -104,11 +104,11 @@ "commonlyUsed": "è comunemente usata per:", "unreliable": "Questa scorciatoia potrebbe non funzionare in modo affidabile o potrebbe avere conflitti con il comportamento del browser/sistema.", "useAnyway": "Vuoi usarla comunque?", - "resetTitle": "Reimposta Scorciatoie", + "resetTitle": "Reimposta scorciatoie", "resetMessage": "Sei sicuro di voler reimpostare tutte le scorciatoie ai valori predefiniti?

Questa azione non può essere annullata.", - "importSuccessTitle": "Importazione Riuscita", + "importSuccessTitle": "Importazione riuscita", "importSuccessMessage": "Scorciatoie importate con successo!", - "importFailTitle": "Importazione Fallita", + "importFailTitle": "Importazione fallita", "importFailMessage": "Impossibile importare le scorciatoie. Formato file non valido." } }, @@ -137,8 +137,8 @@ } }, "faq": { - "title": "Domande Frequenti", - "questions": "Domande", + "title": "Domande", + "questions": "Frequenti", "isFree": { "question": "BentoPDF è davvero gratuito?", "answer": "Sì, assolutamente. Tutti gli strumenti su BentoPDF sono gratuiti al 100%, senza limiti di file, senza registrazioni e senza filigrane. Crediamo che tutti debbano poter accedere a strumenti PDF semplici e potenti senza barriere a pagamento." @@ -174,8 +174,8 @@ }, "testimonials": { "title": "Cosa", - "users": "i Nostri Utenti", - "say": "Dicono" + "users": "dicono i nostri", + "say": "utenti" }, "support": { "title": "Ti piace il mio lavoro?", @@ -186,19 +186,19 @@ "copyright": "© 2025 BentoPDF. Tutti i diritti riservati.", "version": "Versione", "company": "Azienda", - "aboutUs": "Chi Siamo", + "aboutUs": "Chi siamo", "faqLink": "FAQ", "contactUs": "Contattaci", "legal": "Legale", - "termsAndConditions": "Termini e Condizioni", - "privacyPolicy": "Informativa sulla Privacy", + "termsAndConditions": "Termini e condizioni", + "privacyPolicy": "Informativa sulla privacy", "followUs": "Seguici" }, "merge": { "title": "Unisci PDF", "description": "Combina file interi, oppure seleziona pagine specifiche da unire in un nuovo documento.", - "fileMode": "Modalità File", - "pageMode": "Modalità Pagina", + "fileMode": "Modalità file", + "pageMode": "Modalità pagina", "howItWorks": "Come funziona:", "fileModeInstructions": [ "Clicca e trascina l'icona per cambiare l'ordine dei file.", @@ -247,7 +247,7 @@ "title": "Perché BentoPDF", "speed": { "title": "Progettato per la velocità", - "description": "Nessuna attesa per upload o download verso un server. Elaborando i file direttamente nel browser con tecnologie web moderne come WebAssembly, offriamo velocità impareggiabile per tutti i nostri strumenti." + "description": "Nessuna attesa per upload o download verso un server. Elaborando i file direttamente nel browser con tecnologie web moderne come WebAssembly, offriamo una velocità impareggiabile per tutti i nostri strumenti." }, "free": { "title": "Completamente gratuito", @@ -266,58 +266,58 @@ "title": "Pronto per iniziare?", "description": "Unisciti a migliaia di utenti che si affidano a BentoPDF per le loro esigenze quotidiane sui documenti. Sperimenta la differenza che privacy e prestazioni possono offrire.", "button": "Esplora tutti gli strumenti" - }, - "contact": { - "title": "Contattaci", - "subtitle": "Ci farebbe piacere sentirti. Che tu abbia una domanda, un feedback o una richiesta di funzionalità, non esitare a contattarci.", - "email": "Puoi contattarci direttamente via email a:" - }, - "licensing": { - "title": "Licenze per", - "subtitle": "Scegli la licenza che si adatta alle tue esigenze." - }, - "multiTool": { - "uploadPdfs": "Carica PDF", - "upload": "Carica", - "addBlankPage": "Aggiungi pagina vuota", - "edit": "Modifica:", - "undo": "Annulla", - "redo": "Ripeti", - "reset": "Reimposta", - "selection": "Selezione:", - "selectAll": "Seleziona tutto", - "deselectAll": "Deseleziona tutto", - "rotate": "Ruota:", - "rotateLeft": "Sinistra", - "rotateRight": "Destra", - "transform": "Trasforma:", - "duplicate": "Duplica", - "split": "Dividi", - "clear": "Svuota:", - "delete": "Elimina", - "download": "Scarica:", - "downloadSelected": "Scarica selezionati", - "exportPdf": "Esporta PDF", - "uploadPdfFiles": "Seleziona file PDF", - "dragAndDrop": "Trascina e rilascia i file PDF qui, oppure clicca per selezionare", - "selectFiles": "Seleziona file", - "renderingPages": "Generazione anteprime pagine...", - "actions": { - "duplicatePage": "Duplica questa pagina", - "deletePage": "Elimina questa pagina", - "insertPdf": "Inserisci PDF dopo questa pagina", - "toggleSplit": "Attiva la divisione dopo questa pagina" - }, - "pleaseWait": "Attendere...", - "pagesRendering": "Le pagine sono ancora in fase di generazione. Attendere...", - "noPagesSelected": "Nessuna pagina selezionata", - "selectOnePage": "Seleziona almeno una pagina da scaricare.", - "noPages": "Nessuna pagina", - "noPagesToExport": "Non ci sono pagine da esportare.", - "renderingTitle": "Generazione anteprime delle pagine", - "errorRendering": "Impossibile generare le miniature delle pagine", - "error": "Errore", - "failedToLoad": "Caricamento fallito" } + }, + "contact": { + "title": "Contattaci", + "subtitle": "Ci farebbe piacere sentirti. Che tu abbia una domanda, un feedback o una richiesta di funzionalità, non esitare a contattarci.", + "email": "Puoi contattarci direttamente via email a:" + }, + "licensing": { + "title": "Licenze per", + "subtitle": "Scegli la licenza che si adatta alle tue esigenze." + }, + "multiTool": { + "uploadPdfs": "Carica PDF", + "upload": "Carica", + "addBlankPage": "Aggiungi pagina vuota", + "edit": "Modifica:", + "undo": "Annulla", + "redo": "Ripeti", + "reset": "Reimposta", + "selection": "Selezione:", + "selectAll": "Seleziona tutto", + "deselectAll": "Deseleziona tutto", + "rotate": "Ruota:", + "rotateLeft": "Sinistra", + "rotateRight": "Destra", + "transform": "Trasforma:", + "duplicate": "Duplica", + "split": "Dividi", + "clear": "Svuota:", + "delete": "Elimina", + "download": "Scarica:", + "downloadSelected": "Scarica selezionati", + "exportPdf": "Esporta PDF", + "uploadPdfFiles": "Seleziona file PDF", + "dragAndDrop": "Trascina e rilascia i file PDF qui, oppure clicca per selezionare", + "selectFiles": "Seleziona file", + "renderingPages": "Generazione anteprime pagine...", + "actions": { + "duplicatePage": "Duplica questa pagina", + "deletePage": "Elimina questa pagina", + "insertPdf": "Inserisci PDF dopo questa pagina", + "toggleSplit": "Attiva la divisione dopo questa pagina" + }, + "pleaseWait": "Attendere...", + "pagesRendering": "Le pagine sono ancora in fase di generazione. Attendere...", + "noPagesSelected": "Nessuna pagina selezionata", + "selectOnePage": "Seleziona almeno una pagina da scaricare.", + "noPages": "Nessuna pagina", + "noPagesToExport": "Non ci sono pagine da esportare.", + "renderingTitle": "Generazione anteprime delle pagine", + "errorRendering": "Impossibile generare le miniature delle pagine", + "error": "Errore", + "failedToLoad": "Caricamento fallito" } } diff --git a/public/locales/it/tools.json b/public/locales/it/tools.json index 9992060..0caf98e 100644 --- a/public/locales/it/tools.json +++ b/public/locales/it/tools.json @@ -3,18 +3,18 @@ "popularTools": "Strumenti popolari", "editAnnotate": "Modifica e Annota", "convertToPdf": "Converti in PDF", - "convertFromPdf": "Convert da PDF", + "convertFromPdf": "Converti da PDF", "organizeManage": "Organizza e Gestisci", "optimizeRepair": "Ottimizza e Ripara", "securePdf": "Proteggi PDF" }, "pdfMultiTool": { - "name": "PDF Multi Tool", - "subtitle": "Unisci, Dividi, Organizza, Elimina, Ruota, Aggiungi Pagine Vuote, Estrai e Duplica in un'interfaccia unificata." + "name": "Strumento PDF multifunzione", + "subtitle": "Unisci, dividi, organizza, elimina, ruota, aggiungi pagine vuote, estrai e duplica in un'interfaccia unificata." }, "mergePdf": { "name": "Unisci PDF", - "subtitle": "Unisci più PDF in un unico file. Conserva i Segnalibri." + "subtitle": "Unisci più PDF in un unico file. Conserva i segnalibri." }, "splitPdf": { "name": "Dividi PDF", @@ -68,7 +68,7 @@ }, "duplicateOrganize": { "name": "Duplica e Organizza", - "subtitle": "Duplica, riordina e elimina pagine." + "subtitle": "Duplica, riordina ed elimina pagine." }, "deletePages": { "name": "Elimina Pagine", @@ -199,7 +199,7 @@ }, "alternateMix": { "name": "Alterna e Riordina Pagine", - "subtitle": "Unisci PDF sostituendo le pagine di ogni file. Conserva i segnalibri." + "subtitle": "Unisci i PDF alternando le pagine di ogni file. Conserva i segnalibri." }, "addAttachments": { "name": "Aggiungi Allegati", @@ -489,6 +489,33 @@ "note": "Questo strumento funziona SOLO con PDF creati digitalmente. Per documenti scansionati o basati su immagini, usa invece il nostro strumento OCR PDF.", "convertButton": "Estrai testo" }, + "digitalSignPdf": { + "name": "Firma digitale PDF", + "pageTitle": "Firma digitale PDF - Aggiungi firma crittografica | BentoPDF", + "subtitle": "Aggiungi una firma digitale crittografica al tuo PDF usando certificati X.509. Supporta i formati PKCS#12 (.pfx, .p12) e PEM. La tua chiave privata non lascia mai il browser.", + "certificateSection": "Certificato", + "uploadCert": "Carica certificato (.pfx, .p12)", + "certPassword": "Password del certificato", + "certPasswordPlaceholder": "Inserisci la password del certificato", + "certInfo": "Informazioni sul certificato", + "certSubject": "Soggetto", + "certIssuer": "Emittente", + "certValidity": "Valido", + "signatureDetails": "Dettagli della firma (opzionale)", + "reason": "Motivo", + "reasonPlaceholder": "es. Approvo questo documento", + "location": "Luogo", + "locationPlaceholder": "es. Roma, Italia", + "contactInfo": "Contatto", + "contactPlaceholder": "es. email@example.com", + "applySignature": "Applica firma digitale", + "successMessage": "PDF firmato con successo! La firma può essere verificata in qualsiasi lettore PDF." + }, + "validateSignaturePdf": { + "name": "Verifica firma PDF", + "pageTitle": "Verifica firma PDF - Controlla firme digitali | BentoPDF", + "subtitle": "Verifica le firme digitali nei tuoi PDF. Controlla la validità del certificato, visualizza i dati del firmatario e conferma l'integrità del documento. Tutto avviene nel tuo browser." + }, "emailToPdf": { "name": "Email in PDF", "subtitle": "Converti file email (EML, MSG) in formato PDF. Supporta esportazioni Outlook e formati email standard.", From 41970ec330018ba8204f9820e112074ba2451550 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 11 Jan 2026 10:30:57 +0000 Subject: [PATCH 19/73] @gianlucaalfa has signed the CLA in alam00000/bentopdf#380 --- signatures/cla.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/signatures/cla.json b/signatures/cla.json index 8327ee1..c85ba3b 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -247,6 +247,14 @@ "created_at": "2026-01-10T19:30:21Z", "repoId": 1074785178, "pullRequestNo": 379 + }, + { + "name": "gianlucaalfa", + "id": 10059028, + "comment_id": 3734373463, + "created_at": "2026-01-11T10:30:42Z", + "repoId": 1074785178, + "pullRequestNo": 380 } ] } \ No newline at end of file From 584acc68d06bce737969229236332125c775b14a Mon Sep 17 00:00:00 2001 From: Stanislas MEZUREUX Date: Sun, 14 Dec 2025 00:20:40 +0100 Subject: [PATCH 20/73] =?UTF-8?q?feat(i18n):=20Add=20French=20(Fran=C3=A7a?= =?UTF-8?q?is)=20language=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 187 ++++++++++---------- TRANSLATION.md | 38 ++-- nginx.conf | 2 +- public/locales/fr/common.json | 323 ++++++++++++++++++++++++++++++++++ public/locales/fr/tools.json | 282 +++++++++++++++++++++++++++++ src/js/i18n/i18n.ts | 12 +- src/pages/encrypt-pdf.html | 2 +- src/pages/form-creator.html | 1 - vite.config.ts | 2 +- 9 files changed, 733 insertions(+), 116 deletions(-) create mode 100644 public/locales/fr/common.json create mode 100644 public/locales/fr/tools.json diff --git a/README.md b/README.md index 379c25f..534a325 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Have questions, feature requests, or want to chat with the community? Join our D [![Documentation](https://img.shields.io/badge/Docs-VitePress-646cff?style=for-the-badge&logo=vite&logoColor=white)](https://bentopdf.com/docs/) Visit our [Documentation](https://bentopdf.com/docs/) for: + - **Getting Started** guide - **Tools Reference** (50+ tools) - **Self-Hosting** guides (Docker, Vercel, Netlify, Cloudflare, AWS, Hostinger, Nginx, Apache) @@ -75,68 +76,68 @@ BentoPDF offers a comprehensive suite of tools to handle all your PDF needs. ### Organize & Manage PDFs -| Tool Name | Description | -| :------------------------ | :------------------------------------------------------------------------- | -| **Merge PDFs** | Combine multiple PDF files into one. | -| **Split PDFs** | Extract specific pages or divide a document into smaller files. | -| **Organize Pages** | Reorder, duplicate, or delete pages with a simple drag-and-drop interface. | -| **Extract Pages** | Save a specific range of pages as a new PDF. | -| **Delete Pages** | Remove unwanted pages from your document. | -| **Rotate PDF** | Rotate individual or all pages in a document. | -| **N-Up PDF** | Combine multiple pages onto a single page. | -| **View PDF** | A powerful, integrated PDF viewer. | -| **Alternate & Mix pages** | Merge pages by alternating pages from each PDF. | -| **Posterize PDF** | Split a PDF into multiple smaller pages for print. | -| **PDF Multi Tool** | Merge, Split, Organize, Delete, Rotate, Add Blank Pages, Extract and Duplicate in an unified interface. | -| **Add Attachments** | Embed one or more files into your PDF. | -| **Extract Attachments** | Extract all embedded files from PDF(s) as a ZIP. | -| **Edit Attachments** | View or remove attachments in your PDF. | -| **Divide Pages** | Divide pages horizontally or vertically. | -| **Combine to Single Page**| Stitch all pages into one continuous scroll. | -| **Add Blank Page** | Insert an empty page anywhere in your PDF. | -| **Reverse Pages** | Flip the order of all pages in your document. | -| **View Metadata** | Inspect the hidden properties of your PDF. | -| **PDFs to ZIP** | Package multiple PDF files into a ZIP archive. | -| **Compare PDFs** | Compare two PDFs side by side. | +| Tool Name | Description | +| :------------------------- | :------------------------------------------------------------------------------------------------------ | +| **Merge PDFs** | Combine multiple PDF files into one. | +| **Split PDFs** | Extract specific pages or divide a document into smaller files. | +| **Organize Pages** | Reorder, duplicate, or delete pages with a simple drag-and-drop interface. | +| **Extract Pages** | Save a specific range of pages as a new PDF. | +| **Delete Pages** | Remove unwanted pages from your document. | +| **Rotate PDF** | Rotate individual or all pages in a document. | +| **N-Up PDF** | Combine multiple pages onto a single page. | +| **View PDF** | A powerful, integrated PDF viewer. | +| **Alternate & Mix pages** | Merge pages by alternating pages from each PDF. | +| **Posterize PDF** | Split a PDF into multiple smaller pages for print. | +| **PDF Multi Tool** | Merge, Split, Organize, Delete, Rotate, Add Blank Pages, Extract and Duplicate in an unified interface. | +| **Add Attachments** | Embed one or more files into your PDF. | +| **Extract Attachments** | Extract all embedded files from PDF(s) as a ZIP. | +| **Edit Attachments** | View or remove attachments in your PDF. | +| **Divide Pages** | Divide pages horizontally or vertically. | +| **Combine to Single Page** | Stitch all pages into one continuous scroll. | +| **Add Blank Page** | Insert an empty page anywhere in your PDF. | +| **Reverse Pages** | Flip the order of all pages in your document. | +| **View Metadata** | Inspect the hidden properties of your PDF. | +| **PDFs to ZIP** | Package multiple PDF files into a ZIP archive. | +| **Compare PDFs** | Compare two PDFs side by side. | ### Edit & Modify PDFs -| Tool Name | Description | -| :--------------------- | :---------------------------------------------------------- | -| **PDF Editor** | A comprehensive editor to modify your PDFs. | +| Tool Name | Description | +| :------------------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **PDF Editor** | A comprehensive editor to modify your PDFs. | | **Create Fillable Forms** | Create professional fillable PDF forms with text fields, checkboxes, dropdowns, radio buttons, signatures, and more. Fully compliant with PDF standards for compatibility with all PDF viewers. | -| **Add Page Numbers** | Easily add page numbers with customizable formatting. | -| **Add Watermark** | Add text or image watermarks to protect your documents. | -| **Header & Footer** | Add customizable headers and footers. | -| **Crop PDF** | Crop specific pages or the entire document. | -| **Invert Colors** | Invert the colors of your PDF pages for better readability. | -| **Change Background** | Modify the background color of your PDF. | -| **Change Text Color** | Change the color of text content within the PDF. | -| **Fill Forms** | Fill out PDF forms directly in your browser. | -| **Flatten PDF** | Flatten form fields and annotations into static content. | -| **Remove Annotations** | Remove comments, highlights, and other annotations. | -| **Remove Blank Pages** | Auto detect and remove blank pages in a PDF. | -| **Edit Bookmarks** | Add, Edit, Create, Import and Export PDF Bookmarks. | -| **Add Stamps** | Add image stamps to your PDF using the annotation toolbar. | -| **Table of Contents** | Generate a table of contents page from PDF bookmarks. | -| **Redact Content** | Permanently remove sensitive content from your PDFs. | +| **Add Page Numbers** | Easily add page numbers with customizable formatting. | +| **Add Watermark** | Add text or image watermarks to protect your documents. | +| **Header & Footer** | Add customizable headers and footers. | +| **Crop PDF** | Crop specific pages or the entire document. | +| **Invert Colors** | Invert the colors of your PDF pages for better readability. | +| **Change Background** | Modify the background color of your PDF. | +| **Change Text Color** | Change the color of text content within the PDF. | +| **Fill Forms** | Fill out PDF forms directly in your browser. | +| **Flatten PDF** | Flatten form fields and annotations into static content. | +| **Remove Annotations** | Remove comments, highlights, and other annotations. | +| **Remove Blank Pages** | Auto detect and remove blank pages in a PDF. | +| **Edit Bookmarks** | Add, Edit, Create, Import and Export PDF Bookmarks. | +| **Add Stamps** | Add image stamps to your PDF using the annotation toolbar. | +| **Table of Contents** | Generate a table of contents page from PDF bookmarks. | +| **Redact Content** | Permanently remove sensitive content from your PDFs. | ### Convert to PDF | Tool Name | Description | | :------------------ | :-------------------------------------------------------------- | | **Image to PDF** | Convert JPG, PNG, WebP, SVG, BMP, HEIC, and TIFF images to PDF. | -| **JPG to PDF** | Convert JPG images to PDF. | -| **PNG to PDF** | Convert PNG images to PDF. | -| **WebP to PDF** | Convert WebP images to PDF. | -| **SVG to PDF** | Convert SVG images to PDF. | -| **BMP to PDF** | Convert BMP images to PDF. | -| **HEIC to PDF** | Convert HEIC images to PDF. | -| **TIFF to PDF** | Convert TIFF images to PDF. | +| **JPG to PDF** | Convert JPG images to PDF. | +| **PNG to PDF** | Convert PNG images to PDF. | +| **WebP to PDF** | Convert WebP images to PDF. | +| **SVG to PDF** | Convert SVG images to PDF. | +| **BMP to PDF** | Convert BMP images to PDF. | +| **HEIC to PDF** | Convert HEIC images to PDF. | +| **TIFF to PDF** | Convert TIFF images to PDF. | | **Markdown to PDF** | Convert `.md` files into professional PDF documents. | | **Text to PDF** | Convert plain text files into a PDF. | | **EPUB to PDF** | Convert EPUB e-books to PDF format. | -| **JSON to PDF** | Convert JSON to PDF. | +| **JSON to PDF** | Convert JSON to PDF. | ### Convert from PDF @@ -154,24 +155,24 @@ BentoPDF offers a comprehensive suite of tools to handle all your PDF needs. ### Secure & Optimize PDFs -| Tool Name | Description | -| :--------------------- | :----------------------------------------------------------------- | -| **Compress PDF** | Reduce file size while maintaining quality. | -| **Repair PDF** | Attempt to repair and recover data from a corrupted PDF. | -| **Encrypt PDF** | Add a password to protect your PDF from unauthorized access. | -| **Decrypt PDF** | Remove password protection from a PDF (password required). | -| **Change Permissions** | Set or modify user permissions for printing, copying, and editing. | -| **Sign PDF** | Add your digital signature to a document. | -| **Digital Signature** | Add cryptographic digital signatures using X.509 certificates (PFX/PEM). | -| **Validate Signature** | Verify digital signatures and view certificate details. | -| **Redact Content** | Permanently remove sensitive content from your PDFs. | -| **Edit Metadata** | View and modify PDF metadata (author, title, keywords, etc.). | -| **Remove Metadata** | Strip all metadata from your PDF for privacy. | -| **Linearize PDF** | Optimize PDF for fast web view. | -| **Sanitize PDF** | Remove potentially unwanted or malicous files from PDF. | -| **Fix Page Size** | Standardize all pages to a uniform size. | -| **Page Dimensions** | Analyze page size, orientation, and units. | -| **Remove Restrictions**| Remove password protection and security restrictions associated with digitally signed PDF files. | +| Tool Name | Description | +| :---------------------- | :----------------------------------------------------------------------------------------------- | +| **Compress PDF** | Reduce file size while maintaining quality. | +| **Repair PDF** | Attempt to repair and recover data from a corrupted PDF. | +| **Encrypt PDF** | Add a password to protect your PDF from unauthorized access. | +| **Decrypt PDF** | Remove password protection from a PDF (password required). | +| **Change Permissions** | Set or modify user permissions for printing, copying, and editing. | +| **Sign PDF** | Add your digital signature to a document. | +| **Digital Signature** | Add cryptographic digital signatures using X.509 certificates (PFX/PEM). | +| **Validate Signature** | Verify digital signatures and view certificate details. | +| **Redact Content** | Permanently remove sensitive content from your PDFs. | +| **Edit Metadata** | View and modify PDF metadata (author, title, keywords, etc.). | +| **Remove Metadata** | Strip all metadata from your PDF for privacy. | +| **Linearize PDF** | Optimize PDF for fast web view. | +| **Sanitize PDF** | Remove potentially unwanted or malicous files from PDF. | +| **Fix Page Size** | Standardize all pages to a uniform size. | +| **Page Dimensions** | Analyze page size, orientation, and units. | +| **Remove Restrictions** | Remove password protection and security restrictions associated with digitally signed PDF files. | --- @@ -179,12 +180,13 @@ BentoPDF offers a comprehensive suite of tools to handle all your PDF needs. BentoPDF is available in multiple languages: -| Language | Status | -|------------|--------| -| English | [![English](https://img.shields.io/badge/Complete-green?style=flat-square)](public/locales/en/common.json) | -| German | [![German](https://img.shields.io/badge/In_Progress-yellow?style=flat-square)](public/locales/de/common.json) | +| Language | Status | +| ---------- | ----------------------------------------------------------------------------------------------------------------- | +| English | [![English](https://img.shields.io/badge/Complete-green?style=flat-square)](public/locales/en/common.json) | +| German | [![German](https://img.shields.io/badge/In_Progress-yellow?style=flat-square)](public/locales/de/common.json) | | Vietnamese | [![Vietnamese](https://img.shields.io/badge/In_Progress-yellow?style=flat-square)](public/locales/vi/common.json) | -| Chinese | [![Chinese](https://img.shields.io/badge/In_Progress-yellow?style=flat-square)](public/locales/zh/common.json) | +| Chinese | [![Chinese](https://img.shields.io/badge/In_Progress-yellow?style=flat-square)](public/locales/zh/common.json) | +| French | [![French](https://img.shields.io/badge/Complete-green?style=flat-square)](public/locales/fr/common.json) | Want to help translate BentoPDF into your language? Check out our [Translation Guide](TRANSLATION.md)! @@ -227,7 +229,7 @@ This is the fastest way to try BentoPDF without setting up a development environ ### Static Hosting using Netlify, Vercel, and GitHub Pages -It is very straightforward to host your own instance of BentoPDF using a static web page hosting service. Plus, services such as Netlify, Vercel, and GitHub Pages all offer a free tier for getting started. See [Static Hosting](https://github.com/alam00000/bentopdf/blob/main/STATIC-HOSTING.md)) for details. +It is very straightforward to host your own instance of BentoPDF using a static web page hosting service. Plus, services such as Netlify, Vercel, and GitHub Pages all offer a free tier for getting started. See [Static Hosting](https://github.com/alam00000/bentopdf/blob/main/STATIC-HOSTING.md)) for details. ### 🏠 Self-Hosting Locally @@ -241,7 +243,7 @@ The easiest way to self-host is to download the pre-built distribution file from 2. Download the latest `dist-{version}.zip` file 3. Extract the zip file 4. Serve the extracted folder with your preferred web server - + **Serve the extracted folder (requires Node.js):** ```bash @@ -303,12 +305,12 @@ npm run build:all docker build --build-arg COMPRESSION_MODE=all -t bentopdf:all . ``` -| Mode | Files Kept | Use Case | -|------|------------|----------| -| `g` | `.gz` only | Standard nginx or minimal size | -| `b` | `.br` only | Modern CDN with Brotli support | -| `o` | originals | Development or custom compression | -| `all` | all formats | Maximum compatibility (default) | +| Mode | Files Kept | Use Case | +| ----- | ----------- | --------------------------------- | +| `g` | `.gz` only | Standard nginx or minimal size | +| `b` | `.br` only | Modern CDN with Brotli support | +| `o` | originals | Development or custom compression | +| `all` | all formats | Maximum compatibility (default) | **CDN Optimization:** @@ -323,6 +325,7 @@ npm run build ``` **How it works:** + - When `VITE_USE_CDN=true`: Browser loads WASM files from jsDelivr CDN (fast, global delivery) - Local files are **always included** as automatic fallback - If CDN fails then it falls back to local files @@ -348,7 +351,7 @@ cp -r dist/* serve-test/tools/bentopdf/ npx serve serve-test ``` -The website can be accessible at: ```http://localhost:3000/tools/bentopdf/``` +The website can be accessible at: `http://localhost:3000/tools/bentopdf/` The `npm run package` command creates a `dist-{version}.zip` file that you can use for self-hosting. @@ -378,7 +381,8 @@ docker build \ docker run -p 3000:8080 bentopdf-simple ``` -> **Important**: +> **Important**: +> > - Always include trailing slashes in `BASE_URL` (e.g., `/bentopdf/` not `/bentopdf`) > - The default value is `/` for root deployment @@ -441,6 +445,7 @@ For detailed security configuration, see [SECURITY.md](SECURITY.md). The **Digital Signature** tool uses a signing library that may need to fetch certificate chain data from certificate authority provider. Since many certificate servers don't include CORS headers, a proxy is required for this feature to work in the browser. **When is the proxy needed?** + - Only when using the Digital Signature tool - Only if your certificate requires fetching issuer certificates from external URLs - Self-signed certificates typically don't need this @@ -448,16 +453,19 @@ The **Digital Signature** tool uses a signing library that may need to fetch cer **Deploying the CORS Proxy (Cloudflare Workers):** 1. **Navigate to the cloudflare directory:** + ```bash cd cloudflare ``` 2. **Login to Cloudflare (if not already):** + ```bash npx wrangler login ``` 3. **Deploy the worker:** + ```bash npx wrangler deploy ``` @@ -473,13 +481,13 @@ The **Digital Signature** tool uses a signing library that may need to fetch cer The CORS proxy includes several security measures: -| Feature | Description | -|---------|-------------| -| **URL Restrictions** | Only allows certificate URLs (`.crt`, `.cer`, `.pem`, `/certs/`, `/ocsp`) | -| **Private IP Blocking** | Blocks requests to localhost, 10.x, 192.168.x, 172.16-31.x | -| **File Size Limit** | Rejects files larger than 10MB | -| **Rate Limiting** | 60 requests per IP per minute (requires KV) | -| **HMAC Signatures** | Optional client-side signing (limited protection) | +| Feature | Description | +| ----------------------- | ------------------------------------------------------------------------- | +| **URL Restrictions** | Only allows certificate URLs (`.crt`, `.cer`, `.pem`, `/certs/`, `/ocsp`) | +| **Private IP Blocking** | Blocks requests to localhost, 10.x, 192.168.x, 172.16-31.x | +| **File Size Limit** | Rejects files larger than 10MB | +| **Rate Limiting** | 60 requests per IP per minute (requires KV) | +| **HMAC Signatures** | Optional client-side signing (limited protection) | #### Enabling Rate Limiting (Recommended) @@ -657,6 +665,7 @@ npm run docs:preview ``` Documentation files are in the `docs/` folder: + - `docs/index.md` - Home page - `docs/getting-started.md` - Getting started guide - `docs/tools/` - Tools reference diff --git a/TRANSLATION.md b/TRANSLATION.md index 57dc499..e6c3a86 100644 --- a/TRANSLATION.md +++ b/TRANSLATION.md @@ -23,6 +23,7 @@ BentoPDF uses **i18next** for internationalization (i18n). Currently supported l - **German** (`de`) - **Vietnamese** (`vi`) - **Indonesian** (`id`) +- **French** (`fr`) The app automatically detects the language from the URL path: @@ -30,6 +31,7 @@ The app automatically detects the language from the URL path: - `/de/` → German - `/vi/` → Vietnamese - `/id/` → Indonesian +- `/fr/` → French --- @@ -54,33 +56,33 @@ The app automatically detects the language from the URL path: ## Adding a New Language -Let's add **French** as an example: +Let's add **Spanish** as an example: ### Step 1: Create Translation File ```bash # Create the directory -mkdir -p public/locales/fr +mkdir -p public/locales/es # Copy the English template -cp public/locales/en/common.json public/locales/fr/common.json +cp public/locales/en/common.json public/locales/es/common.json ``` ### Step 2: Translate the JSON File -Open `public/locales/fr/common.json` and translate all the values: +Open `public/locales/es/common.json` and translate all the values: ```json { "nav": { - "home": "Accueil", - "about": "À propos", - "contact": "Contact", - "allTools": "Tous les outils" + "home": "Inicio", + "about": "Acerca de", + "contact": "Contacto", + "allTools": "Todas las herramientas" }, "hero": { - "title": "Votre boîte à outils PDF gratuite et sécurisée", - "subtitle": "Fusionnez, divisez, compressez et modifiez des PDF directement dans votre navigateur." + "title": "Tu conjunto de herramientas PDF gratuito y seguro", + "subtitle": "Combina, divide, comprime y edita archivos PDF directamente en tu navegador." } // ... continue translating all keys } @@ -91,13 +93,13 @@ Open `public/locales/fr/common.json` and translate all the values: ✅ **Correct:** ```json -"home": "Accueil" +"home": "Inicio" ``` ❌ **Wrong:** ```json -"accueil": "Accueil" +"inicio": "Inicio" ``` ### Step 3: Register the Language @@ -105,8 +107,8 @@ Open `public/locales/fr/common.json` and translate all the values: Edit `src/js/i18n/i18n.ts`: ```typescript -// Add 'fr' to supported languages -export const supportedLanguages = ['en', 'de', 'fr'] as const; +// Add 'es' to supported languages +export const supportedLanguages = ['en', 'de', 'fr', 'es'] as const; export type SupportedLanguage = (typeof supportedLanguages)[number]; // Add French display name @@ -132,8 +134,8 @@ const langMatch = url.match(/^\/(en|de|zh|vi|it|fr)(\/.*)?$/); # Start the dev server npm run dev -# Visit the French version -# http://localhost:5173/fr/ +# Visit the Spanish version +# http://localhost:5173/es/ ``` --- @@ -283,7 +285,8 @@ In `common.json`: - German: `http://localhost:5173/de/` - Vietnamese: `http://localhost:5173/vi/` - Indonesian: `http://localhost:5173/id/` - - Your new language: `http://localhost:5173/fr/` + - French: `http://localhost:5173/fr/` + - Your new language: `http://localhost:5173/es/` 3. **Check these pages:** - Homepage (`/`) @@ -506,7 +509,6 @@ Current translation coverage: | English | `en` | ✅ Complete | Core team | | German | `de` | 🚧 In Progress | Core team | | Vietnamese | `vi` | ✅ Complete | Community | -| Indonesian | `id` | ✅ Complete | Community | | Your Language | `??` | 🚧 In Progress | You? | --- diff --git a/nginx.conf b/nginx.conf index a4904f5..a4396c2 100644 --- a/nginx.conf +++ b/nginx.conf @@ -26,7 +26,7 @@ http { root /usr/share/nginx/html; index index.html; - rewrite ^/(en|de|zh|vi|it|tr|id)/(.*)$ /$2 last; + rewrite ^/(en|de|zh|vi|it|tr|id|fr)/(.*)$ /$2 last; location ~* \.html$ { expires 1h; diff --git a/public/locales/fr/common.json b/public/locales/fr/common.json new file mode 100644 index 0000000..c48b513 --- /dev/null +++ b/public/locales/fr/common.json @@ -0,0 +1,323 @@ +{ + "nav": { + "home": "Accueil", + "about": "À propos", + "contact": "Contact", + "licensing": "Licence", + "allTools": "Tous les outils", + "openMainMenu": "Ouvrir le menu principal", + "language": "Langue" + }, + "donation": { + "message": "Vous aimez BentoPDF ? Aidez-nous à le garder open source !", + "button": "Soutenir" + }, + "hero": { + "title": "La", + "pdfToolkit": "palette d’outils PDF", + "builtForPrivacy": "conçue pour la confidentialité", + "noSignups": "Sans inscription", + "unlimitedUse": "Utilisation illimitée", + "worksOffline": "Fonctionne hors ligne", + "startUsing": "Commencer maintenant" + }, + "usedBy": { + "title": "Utilisé par des entreprises et des professionnels de" + }, + "features": { + "title": "Pourquoi choisir", + "bentoPdf": "BentoPDF ?", + "noSignup": { + "title": "Sans inscription", + "description": "Utilisation immédiate, sans compte ni email." + }, + "noUploads": { + "title": "Aucun envoi de fichiers", + "description": "100 % côté navigateur, vos fichiers ne quittent jamais votre appareil." + }, + "foreverFree": { + "title": "Gratuit pour toujours", + "description": "Tous les outils, sans essai, sans paiement, sans restrictions." + }, + "noLimits": { + "title": "Sans limites", + "description": "Utilisez autant que vous voulez, sans plafonds cachés." + }, + "batchProcessing": { + "title": "Traitement par lots", + "description": "Gérez un nombre illimité de PDF en une seule fois." + }, + "lightningFast": { + "title": "Ultra rapide", + "description": "Traitez vos PDF instantanément, sans attente." + } + }, + "tools": { + "title": "Commencer avec", + "toolsLabel": "Les outils", + "subtitle": "Cliquez sur un outil pour importer vos fichiers", + "searchPlaceholder": "Rechercher un outil (ex. « scinder », « organiser »...)", + "backToTools": "Retour aux outils", + "firstLoadNotice": "Le premier chargement peut prendre quelques instants, le temps de charger notre moteur de conversion. Les prochaines fois, tout se chargera instantanément." + }, + "upload": { + "clickToSelect": "Cliquez pour sélectionner un fichier", + "orDragAndDrop": "ou glissez-déposez", + "pdfOrImages": "PDF ou images", + "filesNeverLeave": "Vos fichiers restent sur votre appareil.", + "addMore": "Ajouter d’autres fichiers", + "clearAll": "Tout effacer" + }, + "loader": { + "processing": "Traitement en cours..." + }, + "alert": { + "title": "Alerte", + "ok": "OK" + }, + "preview": { + "title": "Aperçu du document", + "downloadAsPdf": "Télécharger en PDF", + "close": "Fermer" + }, + "settings": { + "title": "Paramètres", + "shortcuts": "Raccourcis", + "preferences": "Préférences", + "displayPreferences": "Préférences d’affichage", + "searchShortcuts": "Rechercher un raccourci...", + "shortcutsInfo": "Maintenez les touches pour définir un raccourci. Les changements sont enregistrés automatiquement.", + "shortcutsWarning": "⚠️ Évitez les raccourcis courants du navigateur (Cmd/Ctrl+W, Cmd/Ctrl+T, Cmd/Ctrl+N, etc.), ils peuvent ne pas fonctionner correctement.", + "import": "Importer", + "export": "Exporter", + "resetToDefaults": "Rétablir les paramètres par défaut", + "fullWidthMode": "Mode pleine largeur", + "fullWidthDescription": "Utiliser toute la largeur de l’écran au lieu d’un affichage centré", + "settingsAutoSaved": "Les paramètres sont enregistrés automatiquement", + "clickToSet": "Cliquez pour définir", + "pressKeys": "Appuyez sur les touches...", + "warnings": { + "alreadyInUse": "Raccourci déjà utilisé", + "assignedTo": "est déjà attribué à :", + "chooseDifferent": "Veuillez choisir un autre raccourci.", + "reserved": "Avertissement de raccourci réservé", + "commonlyUsed": "est couramment utilisé pour :", + "unreliable": "Ce raccourci peut ne pas fonctionner correctement ou entrer en conflit avec le navigateur ou le système.", + "useAnyway": "Souhaitez-vous l’utiliser quand même ?", + "resetTitle": "Réinitialiser les raccourcis", + "resetMessage": "Êtes-vous sûr de vouloir réinitialiser tous les raccourcis par défaut ?

Cette action est irréversible.", + "importSuccessTitle": "Importation réussie", + "importSuccessMessage": "Les raccourcis ont été importés avec succès !", + "importFailTitle": "Échec de l’importation", + "importFailMessage": "Impossible d’importer les raccourcis. Format de fichier invalide." + } + }, + "warning": { + "title": "Attention", + "cancel": "Annuler", + "proceed": "Continuer" + }, + "compliance": { + "title": "Vos données ne quittent jamais votre appareil", + "weKeep": "Nous protégeons", + "yourInfoSafe": "vos informations", + "byFollowingStandards": "en respectant les normes de sécurité internationales.", + "processingLocal": "Tous les traitements sont effectués localement sur votre appareil.", + "gdpr": { + "title": "Conformité RGPD", + "description": "Protège les données personnelles et la vie privée des citoyens de l’Union européenne." + }, + "ccpa": { + "title": "Conformité CCPA", + "description": "Accorde aux résidents de Californie des droits sur l’utilisation de leurs données personnelles." + }, + "hipaa": { + "title": "Conformité HIPAA", + "description": "Définit des règles strictes pour la gestion des données de santé aux États-Unis." + } + }, + "faq": { + "title": "Questions", + "questions": "fréquentes", + "isFree": { + "question": "BentoPDF est-il vraiment gratuit ?", + "answer": "Oui, totalement. Tous les outils BentoPDF sont 100 % gratuits, sans limite de fichiers, sans inscription et sans filigrane. Nous pensons que chacun doit avoir accès à des outils PDF simples et puissants, sans barrière payante." + }, + "areFilesSecure": { + "question": "Mes fichiers sont-ils en sécurité ? Où sont-ils traités ?", + "answer": "Vos fichiers sont parfaitement sécurisés car ils ne quittent jamais votre ordinateur. Tous les traitements se font directement dans votre navigateur. Aucun fichier n’est envoyé sur un serveur." + }, + "platforms": { + "question": "Est-ce compatible avec Mac, Windows et mobile ?", + "answer": "Oui ! BentoPDF fonctionne entièrement dans le navigateur et est compatible avec Windows, macOS, Linux, iOS et Android." + }, + "gdprCompliant": { + "question": "BentoPDF est-il conforme au RGPD ?", + "answer": "Oui. Comme tous les traitements sont locaux et qu’aucune donnée n’est collectée ou transmise, vous restez entièrement maître de vos documents." + }, + "dataStorage": { + "question": "Stockez-vous ou suivez-vous mes fichiers ?", + "answer": "Non. Aucun stockage, aucun suivi, aucun historique. Tout disparaît dès que vous fermez la page." + }, + "different": { + "question": "Qu’est-ce qui différencie BentoPDF des autres outils PDF ?", + "answer": "La plupart des outils envoient vos fichiers sur un serveur. BentoPDF traite tout localement dans votre navigateur, pour plus de rapidité, de confidentialité et de tranquillité d’esprit." + }, + "browserBased": { + "question": "Pourquoi le traitement dans le navigateur est-il plus sûr ?", + "answer": "Parce que vos fichiers restent sur votre appareil. Aucun risque de fuite, de piratage ou d’accès non autorisé." + }, + "analytics": { + "question": "Utilisez-vous des cookies ou des outils de suivi ?", + "answer": "Nous respectons votre vie privée. BentoPDF utilise uniquement des statistiques anonymes pour connaître le nombre de visites, sans jamais identifier les utilisateurs." + } + }, + "testimonials": { + "title": "Ce que disent", + "users": "nos utilisateurs", + "say": "" + }, + "support": { + "title": "Vous aimez ce projet ?", + "description": "BentoPDF est un projet passion, créé pour offrir une palette d’outils PDF gratuite, privée et puissante. Si cela vous aide, vous pouvez soutenir son développement. Chaque café compte !", + "buyMeCoffee": "M’offrir un café" + }, + "footer": { + "copyright": "© 2025 BentoPDF. Tous droits réservés.", + "version": "Version", + "company": "Entreprise", + "aboutUs": "À propos", + "faqLink": "FAQ", + "contactUs": "Nous contacter", + "legal": "Mentions légales", + "termsAndConditions": "Conditions générales", + "privacyPolicy": "Politique de confidentialité", + "followUs": "Nous suivre" + }, + "merge": { + "title": "Fusionner des PDF", + "description": "Combinez des fichiers entiers ou sélectionnez des pages spécifiques pour créer un nouveau document.", + "fileMode": "Mode fichiers", + "pageMode": "Mode pages", + "howItWorks": "Fonctionnement :", + "fileModeInstructions": [ + "Cliquez-glissez l’icône pour modifier l’ordre des fichiers.", + "Dans le champ « Pages » de chaque fichier, vous pouvez définir des plages (ex. « 1-3, 5 ») pour ne fusionner que certaines pages.", + "Laissez le champ « Pages » vide pour inclure toutes les pages du fichier." + ], + "pageModeInstructions": [ + "Toutes les pages de vos PDF importés s’affichent ci-dessous.", + "Glissez-déposez simplement les miniatures pour définir l’ordre exact de votre nouveau document." + ], + "mergePdfs": "Fusionner les PDF" + }, + "common": { + "page": "Page", + "pages": "Pages", + "of": "sur", + "download": "Télécharger", + "cancel": "Annuler", + "save": "Enregistrer", + "delete": "Supprimer", + "edit": "Modifier", + "add": "Ajouter", + "remove": "Retirer", + "loading": "Chargement...", + "error": "Erreur", + "success": "Succès", + "file": "Fichier", + "files": "Fichiers" + }, + "about": { + "hero": { + "title": "Nous pensons que les outils PDF doivent être", + "subtitle": "rapides, privés et gratuits.", + "noCompromises": "Sans compromis." + }, + "mission": { + "title": "Notre mission", + "description": "Proposer la palette d’outils PDF la plus complète, tout en respectant votre vie privée et sans jamais demander de paiement. Les outils essentiels doivent être accessibles à tous, partout, sans barrières." + }, + "philosophy": { + "label": "Notre philosophie", + "title": "La confidentialité avant tout. Toujours.", + "description": "À une époque où les données sont devenues une monnaie, nous faisons un choix différent. Tous les traitements des outils BentoPDF sont effectués localement dans votre navigateur. Vos fichiers ne passent jamais par nos serveurs, nous ne voyons jamais vos documents et nous ne suivons pas votre activité. Ce n’est pas une option, c’est notre fondation." + }, + "whyBentopdf": { + "title": "Pourquoi", + "speed": { + "title": "Pensé pour la vitesse", + "description": "Aucune attente liée aux envois ou téléchargements serveur. Grâce au traitement local et aux technologies web modernes comme WebAssembly, nos outils sont extrêmement rapides." + }, + "free": { + "title": "Entièrement gratuit", + "description": "Aucun essai, aucun abonnement, aucun coût caché, aucune fonctionnalité « premium » bloquée. Les outils PDF doivent être un service public, pas un produit de luxe." + }, + "noAccount": { + "title": "Aucun compte requis", + "description": "Utilisez n’importe quel outil immédiatement. Pas d’email, pas de mot de passe, aucune donnée personnelle. Votre flux de travail reste fluide et anonyme." + }, + "openSource": { + "title": "Esprit open source", + "description": "Conçu dans un esprit de transparence. Nous utilisons des bibliothèques open source reconnues comme PDF-lib et PDF.js, et croyons en la force de la communauté." + } + }, + "cta": { + "title": "Prêt à commencer ?", + "description": "Rejoignez des milliers d’utilisateurs qui font confiance à BentoPDF au quotidien. Découvrez la différence qu’apportent la confidentialité et la performance.", + "button": "Explorer tous les outils" + } + }, + "contact": { + "title": "Nous contacter", + "subtitle": "Nous serions ravis d’échanger avec vous. Question, retour ou suggestion de fonctionnalité, n’hésitez pas à nous écrire.", + "email": "Vous pouvez nous contacter directement par email à :" + }, + "licensing": { + "title": "Licences pour", + "subtitle": "Choisissez la licence adaptée à vos besoins." + }, + "multiTool": { + "uploadPdfs": "Importer des PDF", + "upload": "Importer", + "addBlankPage": "Ajouter une page vierge", + "edit": "Modifier :", + "undo": "Annuler", + "redo": "Rétablir", + "reset": "Réinitialiser", + "selection": "Sélection :", + "selectAll": "Tout sélectionner", + "deselectAll": "Tout désélectionner", + "rotate": "Rotation :", + "rotateLeft": "Gauche", + "rotateRight": "Droite", + "transform": "Transformer :", + "duplicate": "Dupliquer", + "split": "Scinder", + "clear": "Effacer :", + "delete": "Supprimer", + "download": "Téléchargement :", + "downloadSelected": "Télécharger la sélection", + "exportPdf": "Exporter en PDF", + "uploadPdfFiles": "Sélectionner des fichiers PDF", + "dragAndDrop": "Glissez-déposez vos fichiers PDF ici ou cliquez pour sélectionner", + "selectFiles": "Sélectionner des fichiers", + "renderingPages": "Rendu des pages...", + "actions": { + "duplicatePage": "Dupliquer cette page", + "deletePage": "Supprimer cette page", + "insertPdf": "Insérer un PDF après cette page", + "toggleSplit": "Activer/désactiver la séparation après cette page" + }, + "pleaseWait": "Veuillez patienter", + "pagesRendering": "Les pages sont en cours de rendu. Veuillez patienter...", + "noPagesSelected": "Aucune page sélectionnée", + "selectOnePage": "Veuillez sélectionner au moins une page à télécharger.", + "noPages": "Aucune page", + "noPagesToExport": "Aucune page à exporter.", + "renderingTitle": "Génération des aperçus", + "errorRendering": "Échec du rendu des miniatures", + "error": "Erreur", + "failedToLoad": "Échec du chargement" + } +} \ No newline at end of file diff --git a/public/locales/fr/tools.json b/public/locales/fr/tools.json new file mode 100644 index 0000000..15a1883 --- /dev/null +++ b/public/locales/fr/tools.json @@ -0,0 +1,282 @@ +{ + "categories": { + "popularTools": "Outils populaires", + "editAnnotate": "Éditer et annoter", + "convertToPdf": "Convertir en PDF", + "convertFromPdf": "Convertir depuis le PDF", + "organizeManage": "Organiser et gérer", + "optimizeRepair": "Optimiser et réparer", + "securePdf": "Sécuriser les PDF" + }, + "pdfMultiTool": { + "name": "Outil PDF tout-en-un", + "subtitle": "Fusionner, scinder, organiser, supprimer, faire pivoter, ajouter des pages vierges, extraire et dupliquer dans une interface unifiée." + }, + "mergePdf": { + "name": "Fusionner des PDF", + "subtitle": "Assembler plusieurs PDF en un seul fichier, tout en conservant les signets." + }, + "splitPdf": { + "name": "Scinder un PDF", + "subtitle": "Extraire une plage de pages dans un nouveau PDF." + }, + "compressPdf": { + "name": "Compresser un PDF", + "subtitle": "Réduire la taille du fichier PDF." + }, + "pdfEditor": { + "name": "Éditeur PDF", + "subtitle": "Annoter, surligner, masquer, commenter, ajouter des formes ou images, rechercher et afficher des PDF." + }, + "jpgToPdf": { + "name": "JPG vers PDF", + "subtitle": "Créer un PDF à partir d’une ou plusieurs images JPG." + }, + "signPdf": { + "name": "Signer un PDF", + "subtitle": "Dessiner, saisir ou importer votre signature." + }, + "cropPdf": { + "name": "Rogner un PDF", + "subtitle": "Ajuster les marges de chaque page du PDF." + }, + "extractPages": { + "name": "Extraire des pages", + "subtitle": "Enregistrer une sélection de pages dans de nouveaux fichiers." + }, + "duplicateOrganize": { + "name": "Dupliquer et organiser", + "subtitle": "Dupliquer, réorganiser et supprimer des pages." + }, + "deletePages": { + "name": "Supprimer des pages", + "subtitle": "Retirer des pages spécifiques du document." + }, + "editBookmarks": { + "name": "Modifier les signets", + "subtitle": "Ajouter, modifier, importer, supprimer et extraire des signets PDF." + }, + "tableOfContents": { + "name": "Table des matières", + "subtitle": "Générer une table des matières à partir des signets du PDF." + }, + "pageNumbers": { + "name": "Numéros de page", + "subtitle": "Insérer une numérotation dans le document." + }, + "addWatermark": { + "name": "Ajouter un filigrane", + "subtitle": "Apposer un texte ou une image sur les pages du PDF." + }, + "headerFooter": { + "name": "En-tête et pied de page", + "subtitle": "Ajouter du texte en haut et en bas des pages." + }, + "invertColors": { + "name": "Inverser les couleurs", + "subtitle": "Créer une version « mode sombre » du PDF." + }, + "backgroundColor": { + "name": "Couleur de fond", + "subtitle": "Modifier la couleur de fond du PDF." + }, + "changeTextColor": { + "name": "Changer la couleur du texte", + "subtitle": "Modifier la couleur du texte dans le PDF." + }, + "addStamps": { + "name": "Ajouter des tampons", + "subtitle": "Ajouter des tampons image via la barre d’annotations.", + "usernameLabel": "Nom du tampon", + "usernamePlaceholder": "Entrez votre nom (pour les tampons)", + "usernameHint": "Ce nom apparaîtra sur les tampons que vous créez." + }, + "removeAnnotations": { + "name": "Supprimer les annotations", + "subtitle": "Retirer les commentaires, surlignages et liens." + }, + "pdfFormFiller": { + "name": "Remplir un formulaire PDF", + "subtitle": "Remplir des formulaires directement dans le navigateur, y compris les formulaires XFA." + }, + "createPdfForm": { + "name": "Créer un formulaire PDF", + "subtitle": "Créer des formulaires PDF interactifs avec des champs glisser-déposer." + }, + "removeBlankPages": { + "name": "Supprimer les pages blanches", + "subtitle": "Détecter et supprimer automatiquement les pages vides." + }, + "imageToPdf": { + "name": "Images vers PDF", + "subtitle": "Convertir JPG, PNG, WebP, BMP, TIFF, SVG et HEIC en PDF." + }, + "pngToPdf": { + "name": "PNG vers PDF", + "subtitle": "Créer un PDF à partir d’une ou plusieurs images PNG." + }, + "webpToPdf": { + "name": "WebP vers PDF", + "subtitle": "Créer un PDF à partir d’une ou plusieurs images WebP." + }, + "svgToPdf": { + "name": "SVG vers PDF", + "subtitle": "Créer un PDF à partir d’une ou plusieurs images SVG." + }, + "bmpToPdf": { + "name": "BMP vers PDF", + "subtitle": "Créer un PDF à partir d’une ou plusieurs images BMP." + }, + "heicToPdf": { + "name": "HEIC vers PDF", + "subtitle": "Créer un PDF à partir d’une ou plusieurs images HEIC." + }, + "tiffToPdf": { + "name": "TIFF vers PDF", + "subtitle": "Créer un PDF à partir d’une ou plusieurs images TIFF." + }, + "textToPdf": { + "name": "Texte vers PDF", + "subtitle": "Convertir un fichier texte en PDF." + }, + "jsonToPdf": { + "name": "JSON vers PDF", + "subtitle": "Convertir des fichiers JSON en PDF." + }, + "pdfToJpg": { + "name": "PDF vers JPG", + "subtitle": "Convertir chaque page du PDF en image JPG." + }, + "pdfToPng": { + "name": "PDF vers PNG", + "subtitle": "Convertir chaque page du PDF en image PNG." + }, + "pdfToWebp": { + "name": "PDF vers WebP", + "subtitle": "Convertir chaque page du PDF en image WebP." + }, + "pdfToBmp": { + "name": "PDF vers BMP", + "subtitle": "Convertir chaque page du PDF en image BMP." + }, + "pdfToTiff": { + "name": "PDF vers TIFF", + "subtitle": "Convertir chaque page du PDF en image TIFF." + }, + "pdfToGreyscale": { + "name": "PDF en niveaux de gris", + "subtitle": "Convertir toutes les couleurs en noir et blanc." + }, + "pdfToJson": { + "name": "PDF vers JSON", + "subtitle": "Convertir des fichiers PDF en JSON." + }, + "ocrPdf": { + "name": "OCR PDF", + "subtitle": "Rendre un PDF consultable et copiable." + }, + "alternateMix": { + "name": "Alterner et mélanger les pages", + "subtitle": "Fusionner des PDF en alternant les pages de chaque fichier, tout en conservant les signets." + }, + "addAttachments": { + "name": "Ajouter des pièces jointes", + "subtitle": "Intégrer un ou plusieurs fichiers dans le PDF." + }, + "extractAttachments": { + "name": "Extraire les pièces jointes", + "subtitle": "Extraire tous les fichiers intégrés des PDF dans une archive ZIP." + }, + "editAttachments": { + "name": "Gérer les pièces jointes", + "subtitle": "Afficher ou supprimer les pièces jointes du PDF." + }, + "dividePages": { + "name": "Diviser les pages", + "subtitle": "Diviser les pages horizontalement ou verticalement." + }, + "addBlankPage": { + "name": "Ajouter une page vierge", + "subtitle": "Insérer une page vide à n’importe quel endroit du PDF." + }, + "reversePages": { + "name": "Inverser l’ordre des pages", + "subtitle": "Renverser l’ordre de toutes les pages du document." + }, + "rotatePdf": { + "name": "Faire pivoter un PDF", + "subtitle": "Tourner les pages par incréments de 90°." + }, + "nUpPdf": { + "name": "PDF N-up", + "subtitle": "Afficher plusieurs pages sur une seule feuille." + }, + "combineToSinglePage": { + "name": "Combiner en une seule page", + "subtitle": "Assembler toutes les pages en un défilement continu." + }, + "viewMetadata": { + "name": "Afficher les métadonnées", + "subtitle": "Consulter les propriétés internes du PDF." + }, + "editMetadata": { + "name": "Modifier les métadonnées", + "subtitle": "Changer l’auteur, le titre et autres propriétés." + }, + "pdfsToZip": { + "name": "PDF vers ZIP", + "subtitle": "Regrouper plusieurs fichiers PDF dans une archive ZIP." + }, + "comparePdfs": { + "name": "Comparer des PDF", + "subtitle": "Comparer deux PDF côte à côte." + }, + "posterizePdf": { + "name": "Posteriser un PDF", + "subtitle": "Découper une grande page en plusieurs pages plus petites." + }, + "fixPageSize": { + "name": "Uniformiser la taille des pages", + "subtitle": "Standardiser toutes les pages à un format identique." + }, + "linearizePdf": { + "name": "Optimiser pour le web", + "subtitle": "Optimiser le PDF pour un affichage rapide en ligne." + }, + "pageDimensions": { + "name": "Dimensions des pages", + "subtitle": "Analyser la taille, l’orientation et les unités des pages." + }, + "removeRestrictions": { + "name": "Supprimer les restrictions", + "subtitle": "Supprimer les protections par mot de passe et restrictions de sécurité des PDF signés." + }, + "repairPdf": { + "name": "Réparer un PDF", + "subtitle": "Récupérer les données de fichiers PDF corrompus ou endommagés." + }, + "encryptPdf": { + "name": "Chiffrer un PDF", + "subtitle": "Protéger le PDF en ajoutant un mot de passe." + }, + "sanitizePdf": { + "name": "Nettoyer un PDF", + "subtitle": "Supprimer les métadonnées, annotations, scripts et autres éléments sensibles." + }, + "decryptPdf": { + "name": "Déverrouiller un PDF", + "subtitle": "Supprimer la protection par mot de passe." + }, + "flattenPdf": { + "name": "Aplatir le PDF", + "subtitle": "Rendre les champs de formulaire et annotations non modifiables." + }, + "removeMetadata": { + "name": "Supprimer les métadonnées", + "subtitle": "Effacer les données cachées du PDF." + }, + "changePermissions": { + "name": "Modifier les autorisations", + "subtitle": "Définir ou modifier les permissions utilisateur du PDF." + } +} \ No newline at end of file diff --git a/src/js/i18n/i18n.ts b/src/js/i18n/i18n.ts index ed10464..7d965ad 100644 --- a/src/js/i18n/i18n.ts +++ b/src/js/i18n/i18n.ts @@ -5,6 +5,7 @@ import HttpBackend from 'i18next-http-backend'; // Supported languages export const supportedLanguages = [ 'en', + 'fr', 'de', 'zh', 'vi', @@ -16,6 +17,7 @@ export type SupportedLanguage = (typeof supportedLanguages)[number]; export const languageNames: Record = { en: 'English', + fr: 'Français', de: 'Deutsch', zh: '中文', vi: 'Tiếng Việt', @@ -26,7 +28,7 @@ export const languageNames: Record = { export const getLanguageFromUrl = (): SupportedLanguage => { const path = window.location.pathname; - const langMatch = path.match(/^\/(en|de|zh|vi|tr|id|it)(?:\/|$)/); + const langMatch = path.match(/^\/(en|fr|de|zh|vi|tr|id|it)(?:\/|$)/); if ( langMatch && supportedLanguages.includes(langMatch[1] as SupportedLanguage) @@ -88,9 +90,9 @@ export const changeLanguage = (lang: SupportedLanguage): void => { const currentLang = getLanguageFromUrl(); let newPath: string; - if (currentPath.match(/^\/(en|de|zh|vi|tr|id|it)\//)) { - newPath = currentPath.replace(/^\/(en|de|zh|vi|tr|id|it)\//, `/${lang}/`); - } else if (currentPath.match(/^\/(en|de|zh|vi|tr|id|it)$/)) { + if (currentPath.match(/^\/(en|fr|de|zh|vi|tr|id|it)\//)) { + newPath = currentPath.replace(/^\/(en|fr|de|zh|vi|tr|id|it)\//, `/${lang}/`); + } else if (currentPath.match(/^\/(en|fr|de|zh|vi|tr|id|it)$/)) { newPath = `/${lang}`; } else { newPath = `/${lang}${currentPath}`; @@ -154,7 +156,7 @@ export const rewriteLinks = (): void => { return; } - if (href.match(/^\/(en|de|zh|vi|tr|id|it)\//)) { + if (href.match(/^\/(en|fr|de|zh|vi|tr|id|it)\//)) { return; } let newHref: string; diff --git a/src/pages/encrypt-pdf.html b/src/pages/encrypt-pdf.html index a1f0229..01ba8e5 100644 --- a/src/pages/encrypt-pdf.html +++ b/src/pages/encrypt-pdf.html @@ -1,4 +1,4 @@ - +s diff --git a/src/pages/form-creator.html b/src/pages/form-creator.html index 2463b5b..00b0a94 100644 --- a/src/pages/form-creator.html +++ b/src/pages/form-creator.html @@ -63,7 +63,6 @@ Create PDF Form - BentoPDF - diff --git a/vite.config.ts b/vite.config.ts index c4caa12..b420f01 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,7 +14,7 @@ function pagesRewritePlugin(): Plugin { server.middlewares.use((req, res, next) => { const url = req.url?.split('?')[0] || ''; - const langMatch = url.match(/^\/(en|de|zh|vi|it|id|tr)(\/.*)?$/); + const langMatch = url.match(/^\/(en|de|zh|vi|it|id|tr|fr)(\/.*)?$/); if (langMatch) { const lang = langMatch[1]; const restOfPath = langMatch[2] || '/'; From ec830880e7c35033e2bf82fd6752c5de46382848 Mon Sep 17 00:00:00 2001 From: Stanislas MEZUREUX Date: Mon, 12 Jan 2026 00:02:28 +0100 Subject: [PATCH 21/73] chore(i18n): add new translation keys --- public/locales/fr/tools.json | 257 ++++++++++++++++++++++++++++++++++- 1 file changed, 254 insertions(+), 3 deletions(-) diff --git a/public/locales/fr/tools.json b/public/locales/fr/tools.json index 15a1883..8f36e7c 100644 --- a/public/locales/fr/tools.json +++ b/public/locales/fr/tools.json @@ -22,7 +22,29 @@ }, "compressPdf": { "name": "Compresser un PDF", - "subtitle": "Réduire la taille du fichier PDF." + "subtitle": "Réduire la taille du fichier PDF.", + "algorithmLabel": "Algorithme de compression", + "condense": "Condensé (recommandé)", + "photon": "Photon (pour les PDF riches en photos)", + "condenseInfo": "Condensé utilise une compression avancée : suppression du superflu, optimisation des images, sous-ensemble des polices. Idéal pour la plupart des PDF.", + "photonInfo": "Photon convertit les pages en images. À utiliser pour les PDF contenant beaucoup de photos ou scannés.", + "photonWarning": "Attention : le texte ne sera plus sélectionnable et les liens ne fonctionneront plus.", + "levelLabel": "Niveau de compression", + "light": "Léger (préserver la qualité)", + "balanced": "Équilibré (recommandé)", + "aggressive": "Agressif (fichiers plus petits)", + "extreme": "Extrême (compression maximale)", + "grayscale": "Convertir en niveaux de gris", + "grayscaleHint": "Réduit la taille du fichier en supprimant les informations de couleur", + "customSettings": "Paramètres personnalisés", + "customSettingsHint": "Affiner les paramètres de compression :", + "outputQuality": "Qualité de sortie", + "resizeImagesTo": "Redimensionner les images à", + "onlyProcessAbove": "Traiter uniquement au-dessus de", + "removeMetadata": "Supprimer les métadonnées", + "subsetFonts": "Sous-ensemble des polices (supprimer les glyphes inutilisés)", + "removeThumbnails": "Supprimer les vignettes intégrées", + "compressButton": "Compresser le PDF" }, "pdfEditor": { "name": "Éditeur PDF", @@ -109,7 +131,7 @@ }, "imageToPdf": { "name": "Images vers PDF", - "subtitle": "Convertir JPG, PNG, WebP, BMP, TIFF, SVG et HEIC en PDF." + "subtitle": "Convertir un JPG, PNG, BMP, GIF, TIFF, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2, PSD, SVG, HEIC, WebP en PDF." }, "pngToPdf": { "name": "PNG vers PDF", @@ -207,6 +229,10 @@ "name": "Faire pivoter un PDF", "subtitle": "Tourner les pages par incréments de 90°." }, + "rotateCustom": { + "name": "Rotation par angle personnalisé", + "subtitle": "Faire pivoter les pages selon un angle personnalisé." + }, "nUpPdf": { "name": "PDF N-up", "subtitle": "Afficher plusieurs pages sur une seule feuille." @@ -278,5 +304,230 @@ "changePermissions": { "name": "Modifier les autorisations", "subtitle": "Définir ou modifier les permissions utilisateur du PDF." + }, + "odtToPdf": { + "name": "ODT vers PDF", + "subtitle": "Convertir des fichiers OpenDocument Text au format PDF. Prend en charge plusieurs fichiers.", + "acceptedFormats": "Fichiers ODT", + "convertButton": "Convertir en PDF" + }, + "csvToPdf": { + "name": "CSV vers PDF", + "subtitle": "Convertir des fichiers tableur CSV au format PDF. Prend en charge plusieurs fichiers.", + "acceptedFormats": "Fichiers CSV", + "convertButton": "Convertir en PDF" + }, + "rtfToPdf": { + "name": "RTF vers PDF", + "subtitle": "Convertir des documents Rich Text Format en PDF. Prend en charge plusieurs fichiers.", + "acceptedFormats": "Fichiers RTF", + "convertButton": "Convertir en PDF" + }, + "wordToPdf": { + "name": "Word vers PDF", + "subtitle": "Convertir des documents Word (DOCX, DOC, ODT, RTF) au format PDF. Prend en charge plusieurs fichiers.", + "acceptedFormats": "Fichiers DOCX, DOC, ODT, RTF", + "convertButton": "Convertir en PDF" + }, + "excelToPdf": { + "name": "Excel vers PDF", + "subtitle": "Convertir des feuilles de calcul Excel (XLSX, XLS, ODS, CSV) au format PDF. Prend en charge plusieurs fichiers.", + "acceptedFormats": "Fichiers XLSX, XLS, ODS, CSV", + "convertButton": "Convertir en PDF" + }, + "powerpointToPdf": { + "name": "PowerPoint vers PDF", + "subtitle": "Convertir des présentations PowerPoint (PPTX, PPT, ODP) au format PDF. Prend en charge plusieurs fichiers.", + "acceptedFormats": "Fichiers PPTX, PPT, ODP", + "convertButton": "Convertir en PDF" + }, + "markdownToPdf": { + "name": "Markdown vers PDF", + "subtitle": "Écrire ou coller du Markdown et l’exporter en PDF avec une mise en forme soignée.", + "paneMarkdown": "Markdown", + "panePreview": "Aperçu", + "btnUpload": "Téléverser", + "btnSyncScroll": "Synchroniser le défilement", + "btnSettings": "Paramètres", + "btnExportPdf": "Exporter en PDF", + "settingsTitle": "Paramètres Markdown", + "settingsPreset": "Préréglage", + "presetDefault": "Par défaut (type GFM)", + "presetCommonmark": "CommonMark (strict)", + "presetZero": "Minimal (aucune fonctionnalité)", + "settingsOptions": "Options Markdown", + "optAllowHtml": "Autoriser les balises HTML", + "optBreaks": "Convertir les retours à la ligne en
", + "optLinkify": "Convertir automatiquement les URL en liens", + "optTypographer": "Typographie (guillemets intelligents, etc.)" + }, + "pdfBooklet": { + "name": "Livret PDF", + "subtitle": "Réorganiser les pages pour l’impression recto verso en livret. Pliez et agrafez pour créer un livret.", + "howItWorks": "Fonctionnement :", + "step1": "Téléversez un fichier PDF.", + "step2": "Les pages seront réorganisées dans l’ordre du livret.", + "step3": "Imprimez en recto verso, retournement sur le bord court, pliez et agrafez.", + "paperSize": "Format du papier", + "orientation": "Orientation", + "portrait": "Portrait", + "landscape": "Paysage", + "pagesPerSheet": "Pages par feuille", + "createBooklet": "Créer le livret", + "processing": "Traitement...", + "pageCount": "Le nombre de pages sera complété au multiple de 4 si nécessaire." + }, + "xpsToPdf": { + "name": "XPS vers PDF", + "subtitle": "Convertir des documents XPS/OXPS au format PDF. Prend en charge plusieurs fichiers.", + "acceptedFormats": "Fichiers XPS, OXPS", + "convertButton": "Convertir en PDF" + }, + "mobiToPdf": { + "name": "MOBI vers PDF", + "subtitle": "Convertir des livres numériques MOBI au format PDF. Prend en charge plusieurs fichiers.", + "acceptedFormats": "Fichiers MOBI", + "convertButton": "Convertir en PDF" + }, + "epubToPdf": { + "name": "EPUB vers PDF", + "subtitle": "Convertir des livres numériques EPUB au format PDF. Prend en charge plusieurs fichiers.", + "acceptedFormats": "Fichiers EPUB", + "convertButton": "Convertir en PDF" + }, + "fb2ToPdf": { + "name": "FB2 vers PDF", + "subtitle": "Convertir des livres numériques FictionBook (FB2) au format PDF. Prend en charge plusieurs fichiers.", + "acceptedFormats": "Fichiers FB2", + "convertButton": "Convertir en PDF" + }, + "cbzToPdf": { + "name": "CBZ vers PDF", + "subtitle": "Convertir des archives de bandes dessinées (CBZ/CBR) au format PDF. Prend en charge plusieurs fichiers.", + "acceptedFormats": "Fichiers CBZ, CBR", + "convertButton": "Convertir en PDF" + }, + "wpdToPdf": { + "name": "WPD vers PDF", + "subtitle": "Convertir des documents WordPerfect (WPD) au format PDF. Prend en charge plusieurs fichiers.", + "acceptedFormats": "Fichiers WPD", + "convertButton": "Convertir en PDF" + }, + "wpsToPdf": { + "name": "WPS vers PDF", + "subtitle": "Convertir des documents WPS Office au format PDF. Prend en charge plusieurs fichiers.", + "acceptedFormats": "Fichiers WPS", + "convertButton": "Convertir en PDF" + }, + "xmlToPdf": { + "name": "XML vers PDF", + "subtitle": "Convertir des documents XML au format PDF. Prend en charge plusieurs fichiers.", + "acceptedFormats": "Fichiers XML", + "convertButton": "Convertir en PDF" + }, + "pagesToPdf": { + "name": "Pages vers PDF", + "subtitle": "Convertir des documents Apple Pages au format PDF. Prend en charge plusieurs fichiers.", + "acceptedFormats": "Fichiers Pages", + "convertButton": "Convertir en PDF" + }, + "odgToPdf": { + "name": "ODG vers PDF", + "subtitle": "Convertir des fichiers OpenDocument Graphics (ODG) au format PDF. Prend en charge plusieurs fichiers.", + "acceptedFormats": "Fichiers ODG", + "convertButton": "Convertir en PDF" + }, + "odsToPdf": { + "name": "ODS vers PDF", + "subtitle": "Convertir des fichiers OpenDocument Spreadsheet (ODS) au format PDF. Prend en charge plusieurs fichiers.", + "acceptedFormats": "Fichiers ODS", + "convertButton": "Convertir en PDF" + }, + "odpToPdf": { + "name": "ODP vers PDF", + "subtitle": "Convertir des fichiers OpenDocument Presentation (ODP) au format PDF. Prend en charge plusieurs fichiers.", + "acceptedFormats": "Fichiers ODP", + "convertButton": "Convertir en PDF" + }, + "pubToPdf": { + "name": "PUB vers PDF", + "subtitle": "Convertir des fichiers Microsoft Publisher (PUB) au format PDF. Prend en charge plusieurs fichiers.", + "acceptedFormats": "Fichiers PUB", + "convertButton": "Convertir en PDF" + }, + "vsdToPdf": { + "name": "VSD vers PDF", + "subtitle": "Convertir des fichiers Microsoft Visio (VSD, VSDX) au format PDF. Prend en charge plusieurs fichiers.", + "acceptedFormats": "Fichiers VSD, VSDX", + "convertButton": "Convertir en PDF" + }, + "psdToPdf": { + "name": "PSD vers PDF", + "subtitle": "Convertir des fichiers Adobe Photoshop (PSD) au format PDF. Prend en charge plusieurs fichiers.", + "acceptedFormats": "Fichiers PSD", + "convertButton": "Convertir en PDF" + }, + "pdfToSvg": { + "name": "PDF vers SVG", + "subtitle": "Convertir chaque page d’un fichier PDF en graphique vectoriel évolutif (SVG) pour une qualité parfaite à toutes les tailles." + }, + "extractTables": { + "name": "Extraire les tableaux PDF", + "subtitle": "Extraire les tableaux des fichiers PDF et les exporter en CSV, JSON ou Markdown." + }, + "pdfToCsv": { + "name": "PDF vers CSV", + "subtitle": "Extraire les tableaux d’un PDF et les convertir au format CSV." + }, + "pdfToExcel": { + "name": "PDF vers Excel", + "subtitle": "Extraire les tableaux d’un PDF et les convertir au format Excel (XLSX)." + }, + "pdfToText": { + "name": "PDF vers texte", + "subtitle": "Extraire le texte des fichiers PDF et l’enregistrer en texte brut (.txt). Prend en charge plusieurs fichiers.", + "note": "Cet outil fonctionne UNIQUEMENT avec des PDF créés numériquement. Pour les documents scannés ou les PDF basés sur des images, utilisez plutôt notre outil OCR PDF.", + "convertButton": "Extraire le texte" + }, + "digitalSignPdf": { + "name": "Signature numérique PDF", + "pageTitle": "Signature numérique PDF - Ajouter une signature cryptographique | BentoPDF", + "subtitle": "Ajouter une signature numérique cryptographique à votre PDF à l’aide de certificats X.509. Prend en charge les formats PKCS#12 (.pfx, .p12) et PEM. Votre clé privée ne quitte jamais votre navigateur.", + "certificateSection": "Certificat", + "uploadCert": "Téléverser un certificat (.pfx, .p12)", + "certPassword": "Mot de passe du certificat", + "certPasswordPlaceholder": "Saisissez le mot de passe du certificat", + "certInfo": "Informations du certificat", + "certSubject": "Sujet", + "certIssuer": "Émetteur", + "certValidity": "Validité", + "signatureDetails": "Détails de la signature (facultatif)", + "reason": "Motif", + "reasonPlaceholder": "ex. : J’approuve ce document", + "location": "Lieu", + "locationPlaceholder": "ex. : Paris, France", + "contactInfo": "Coordonnées", + "contactPlaceholder": "ex. : email@exemple.com", + "applySignature": "Appliquer la signature numérique", + "successMessage": "PDF signé avec succès ! La signature peut être vérifiée dans n’importe quel lecteur PDF." + }, + "validateSignaturePdf": { + "name": "Valider la signature PDF", + "pageTitle": "Valider la signature PDF - Vérifier les signatures numériques | BentoPDF", + "subtitle": "Vérifier les signatures numériques de vos fichiers PDF. Contrôlez la validité du certificat, consultez les informations du signataire et confirmez l’intégrité du document. Tout le traitement s’effectue dans votre navigateur." + }, + "emailToPdf": { + "name": "Email vers PDF", + "subtitle": "Convertir des fichiers email (EML, MSG) au format PDF. Prend en charge les exports Outlook et les formats email standards.", + "acceptedFormats": "Fichiers EML, MSG", + "convertButton": "Convertir en PDF" + }, + "fontToOutline": { + "name": "Polices en contours", + "subtitle": "Convertir toutes les polices en contours vectoriels pour un rendu cohérent sur tous les appareils." + }, + "deskewPdf": { + "name": "Redresser un PDF", + "subtitle": "Redresser automatiquement les pages scannées inclinées à l’aide d’OpenCV." } -} \ No newline at end of file +} From cf61212515680e610f348297798f4be094662469 Mon Sep 17 00:00:00 2001 From: abdullahalam123 Date: Mon, 12 Jan 2026 13:30:54 +0530 Subject: [PATCH 22/73] feat(form-creator): add custom date formats, fix duplicate field bug, improve sticky UX Bug Fixes: - Fix duplicate field appearing when removing all options from dropdown/list and dragging - Fix selected tool button ring being clipped by removing overflow-hidden from Fields toolbar Date Format Enhancements: - Add all 30 Adobe Acrobat-compatible date formats - Add ISO 8601 formats (yyyy-mm-dd, yyyy-mm, yyyy) - Add European dot-separated formats (dd.mm.yyyy, dd.mm.yy) - Add date-time formats with 12h/24h time support - Add Custom format option with input field - Add live date format example preview UI/UX Improvements: - Make Properties sidebar sticky on large screens - Make Page Management toolbar sticky below navbar - Make Fields toolbar sticky below Page Management toolbar - Increase Fields toolbar padding for better spacing - Fix browser note text alignment in date properties --- src/js/logic/form-creator.ts | 4283 +++++++++++++++++++--------------- src/pages/form-creator.html | 12 +- 2 files changed, 2382 insertions(+), 1913 deletions(-) diff --git a/src/js/logic/form-creator.ts b/src/js/logic/form-creator.ts index a50a33d..98854b7 100644 --- a/src/js/logic/form-creator.ts +++ b/src/js/logic/form-creator.ts @@ -1,780 +1,918 @@ -import { PDFDocument, StandardFonts, rgb, TextAlignment, PDFName, PDFString, PageSizes, PDFBool, PDFDict, PDFArray, PDFRadioGroup } from 'pdf-lib' -import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js' -import { downloadFile, hexToRgb, getPDFDocument } from '../utils/helpers.js' -import { createIcons, icons } from 'lucide' -import * as pdfjsLib from 'pdfjs-dist' -import 'pdfjs-dist/web/pdf_viewer.css' +import { + PDFDocument, + StandardFonts, + rgb, + TextAlignment, + PDFName, + PDFString, + PageSizes, + PDFBool, + PDFDict, + PDFArray, + PDFRadioGroup, +} from 'pdf-lib'; +import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js'; +import { downloadFile, hexToRgb, getPDFDocument } from '../utils/helpers.js'; +import { createIcons, icons } from 'lucide'; +import * as pdfjsLib from 'pdfjs-dist'; +import 'pdfjs-dist/web/pdf_viewer.css'; // Initialize PDF.js worker -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(); -import { FormField, PageData } from '../types/index.js' +import { FormField, PageData } from '../types/index.js'; +let fields: FormField[] = []; +let selectedField: FormField | null = null; +let fieldCounter = 0; +const existingFieldNames: Set = new Set(); +const existingRadioGroups: Set = new Set(); +let draggedElement: HTMLElement | null = null; +let offsetX = 0; +let offsetY = 0; -let fields: FormField[] = [] -let selectedField: FormField | null = null -let fieldCounter = 0 -const existingFieldNames: Set = new Set() -const existingRadioGroups: Set = new Set() -let draggedElement: HTMLElement | null = null -let offsetX = 0 -let offsetY = 0 +let pages: PageData[] = []; +let currentPageIndex = 0; +let uploadedPdfDoc: PDFDocument | null = null; +let uploadedPdfjsDoc: any = null; +let pageSize: { width: number; height: number } = { width: 612, height: 792 }; +let currentScale = 1.333; +let pdfViewerOffset = { x: 0, y: 0 }; +let pdfViewerScale = 1.333; -let pages: PageData[] = [] -let currentPageIndex = 0 -let uploadedPdfDoc: PDFDocument | null = null -let uploadedPdfjsDoc: any = null -let pageSize: { width: number; height: number } = { width: 612, height: 792 } -let currentScale = 1.333 -let pdfViewerOffset = { x: 0, y: 0 } -let pdfViewerScale = 1.333 +let resizing = false; +let resizeField: FormField | null = null; +let resizePos: string | null = null; +let startX = 0; +let startY = 0; +let startWidth = 0; +let startHeight = 0; +let startLeft = 0; +let startTop = 0; -let resizing = false -let resizeField: FormField | null = null -let resizePos: string | null = null -let startX = 0 -let startY = 0 -let startWidth = 0 -let startHeight = 0 -let startLeft = 0 -let startTop = 0 +let selectedToolType: string | null = null; -let selectedToolType: string | null = null +const canvas = document.getElementById('pdfCanvas') as HTMLDivElement; +const propertiesPanel = document.getElementById( + 'propertiesPanel' +) as HTMLDivElement; +const fieldCountDisplay = document.getElementById( + 'fieldCount' +) as HTMLSpanElement; +const uploadArea = document.getElementById('upload-area') as HTMLDivElement; +const toolContainer = document.getElementById( + 'tool-container' +) as HTMLDivElement; +const dropZone = document.getElementById('dropZone') as HTMLDivElement; +const pdfFileInput = document.getElementById( + 'pdfFileInput' +) as HTMLInputElement; +const blankPdfBtn = document.getElementById('blankPdfBtn') as HTMLButtonElement; +const pdfUploadInput = document.getElementById( + 'pdfUploadInput' +) as HTMLInputElement; +const pageSizeSelector = document.getElementById( + 'pageSizeSelector' +) as HTMLDivElement; +const pageSizeSelect = document.getElementById( + 'pageSizeSelect' +) as HTMLSelectElement; +const customDimensionsInput = document.getElementById( + 'customDimensionsInput' +) as HTMLDivElement; +const customWidth = document.getElementById('customWidth') as HTMLInputElement; +const customHeight = document.getElementById( + 'customHeight' +) as HTMLInputElement; +const confirmBlankBtn = document.getElementById( + 'confirmBlankBtn' +) as HTMLButtonElement; +const pageIndicator = document.getElementById( + 'pageIndicator' +) as HTMLSpanElement; +const prevPageBtn = document.getElementById('prevPageBtn') as HTMLButtonElement; +const nextPageBtn = document.getElementById('nextPageBtn') as HTMLButtonElement; +const addPageBtn = document.getElementById('addPageBtn') as HTMLButtonElement; +const resetBtn = document.getElementById('resetBtn') as HTMLButtonElement; +const downloadBtn = document.getElementById('downloadBtn') as HTMLButtonElement; +const backToToolsBtn = document.getElementById( + 'back-to-tools' +) as HTMLButtonElement | null; +const gotoPageInput = document.getElementById( + 'gotoPageInput' +) as HTMLInputElement; +const gotoPageBtn = document.getElementById('gotoPageBtn') as HTMLButtonElement; -const canvas = document.getElementById('pdfCanvas') as HTMLDivElement -const propertiesPanel = document.getElementById('propertiesPanel') as HTMLDivElement -const fieldCountDisplay = document.getElementById('fieldCount') as HTMLSpanElement -const uploadArea = document.getElementById('upload-area') as HTMLDivElement -const toolContainer = document.getElementById('tool-container') as HTMLDivElement -const dropZone = document.getElementById('dropZone') as HTMLDivElement -const pdfFileInput = document.getElementById('pdfFileInput') as HTMLInputElement -const blankPdfBtn = document.getElementById('blankPdfBtn') as HTMLButtonElement -const pdfUploadInput = document.getElementById('pdfUploadInput') as HTMLInputElement -const pageSizeSelector = document.getElementById('pageSizeSelector') as HTMLDivElement -const pageSizeSelect = document.getElementById('pageSizeSelect') as HTMLSelectElement -const customDimensionsInput = document.getElementById('customDimensionsInput') as HTMLDivElement -const customWidth = document.getElementById('customWidth') as HTMLInputElement -const customHeight = document.getElementById('customHeight') as HTMLInputElement -const confirmBlankBtn = document.getElementById('confirmBlankBtn') as HTMLButtonElement -const pageIndicator = document.getElementById('pageIndicator') as HTMLSpanElement -const prevPageBtn = document.getElementById('prevPageBtn') as HTMLButtonElement -const nextPageBtn = document.getElementById('nextPageBtn') as HTMLButtonElement -const addPageBtn = document.getElementById('addPageBtn') as HTMLButtonElement -const resetBtn = document.getElementById('resetBtn') as HTMLButtonElement -const downloadBtn = document.getElementById('downloadBtn') as HTMLButtonElement -const backToToolsBtn = document.getElementById('back-to-tools') as HTMLButtonElement | null -const gotoPageInput = document.getElementById('gotoPageInput') as HTMLInputElement -const gotoPageBtn = document.getElementById('gotoPageBtn') as HTMLButtonElement - -const gridVInput = document.getElementById('gridVInput') as HTMLInputElement -const gridHInput = document.getElementById('gridHInput') as HTMLInputElement -const toggleGridBtn = document.getElementById('toggleGridBtn') as HTMLButtonElement -const enableGridCheckbox = document.getElementById('enableGridCheckbox') as HTMLInputElement -let gridV = 2 -let gridH = 2 -let gridAlwaysVisible = false -let gridEnabled = true +const gridVInput = document.getElementById('gridVInput') as HTMLInputElement; +const gridHInput = document.getElementById('gridHInput') as HTMLInputElement; +const toggleGridBtn = document.getElementById( + 'toggleGridBtn' +) as HTMLButtonElement; +const enableGridCheckbox = document.getElementById( + 'enableGridCheckbox' +) as HTMLInputElement; +let gridV = 2; +let gridH = 2; +let gridAlwaysVisible = false; +let gridEnabled = true; if (gridVInput && gridHInput) { - gridVInput.value = '2' - gridHInput.value = '2' + gridVInput.value = '2'; + gridHInput.value = '2'; - const updateGrid = () => { - let v = parseInt(gridVInput.value) || 2 - let h = parseInt(gridHInput.value) || 2 + const updateGrid = () => { + let v = parseInt(gridVInput.value) || 2; + let h = parseInt(gridHInput.value) || 2; - if (v < 2) { v = 2; gridVInput.value = '2' } - if (h < 2) { h = 2; gridHInput.value = '2' } - if (v > 14) { v = 14; gridVInput.value = '14' } - if (h > 14) { h = 14; gridHInput.value = '14' } - - gridV = v - gridH = h - - if (gridAlwaysVisible && gridEnabled) { - renderGrid() - } + if (v < 2) { + v = 2; + gridVInput.value = '2'; + } + if (h < 2) { + h = 2; + gridHInput.value = '2'; + } + if (v > 14) { + v = 14; + gridVInput.value = '14'; + } + if (h > 14) { + h = 14; + gridHInput.value = '14'; } - gridVInput.addEventListener('input', updateGrid) - gridHInput.addEventListener('input', updateGrid) + gridV = v; + gridH = h; + + if (gridAlwaysVisible && gridEnabled) { + renderGrid(); + } + }; + + gridVInput.addEventListener('input', updateGrid); + gridHInput.addEventListener('input', updateGrid); } if (enableGridCheckbox) { - enableGridCheckbox.addEventListener('change', (e) => { - gridEnabled = (e.target as HTMLInputElement).checked + enableGridCheckbox.addEventListener('change', (e) => { + gridEnabled = (e.target as HTMLInputElement).checked; - if (!gridEnabled) { - removeGrid() - if (gridVInput) gridVInput.disabled = true - if (gridHInput) gridHInput.disabled = true - if (toggleGridBtn) toggleGridBtn.disabled = true - } else { - if (gridVInput) gridVInput.disabled = false - if (gridHInput) gridHInput.disabled = false - if (toggleGridBtn) toggleGridBtn.disabled = false - if (gridAlwaysVisible) renderGrid() - } - }) + if (!gridEnabled) { + removeGrid(); + if (gridVInput) gridVInput.disabled = true; + if (gridHInput) gridHInput.disabled = true; + if (toggleGridBtn) toggleGridBtn.disabled = true; + } else { + if (gridVInput) gridVInput.disabled = false; + if (gridHInput) gridHInput.disabled = false; + if (toggleGridBtn) toggleGridBtn.disabled = false; + if (gridAlwaysVisible) renderGrid(); + } + }); } if (toggleGridBtn) { - toggleGridBtn.addEventListener('click', () => { - gridAlwaysVisible = !gridAlwaysVisible + toggleGridBtn.addEventListener('click', () => { + gridAlwaysVisible = !gridAlwaysVisible; - if (gridAlwaysVisible) { - toggleGridBtn.classList.add('bg-indigo-600') - toggleGridBtn.classList.remove('bg-gray-600') - if (gridEnabled) renderGrid() - } else { - toggleGridBtn.classList.remove('bg-indigo-600') - toggleGridBtn.classList.add('bg-gray-600') - removeGrid() - } - }) + if (gridAlwaysVisible) { + toggleGridBtn.classList.add('bg-indigo-600'); + toggleGridBtn.classList.remove('bg-gray-600'); + if (gridEnabled) renderGrid(); + } else { + toggleGridBtn.classList.remove('bg-indigo-600'); + toggleGridBtn.classList.add('bg-gray-600'); + removeGrid(); + } + }); } function renderGrid() { - const existingGrid = document.getElementById('pdfGrid') - if (existingGrid) existingGrid.remove() + const existingGrid = document.getElementById('pdfGrid'); + if (existingGrid) existingGrid.remove(); - const gridContainer = document.createElement('div') - gridContainer.id = 'pdfGrid' - gridContainer.className = 'absolute inset-0 pointer-events-none' - gridContainer.style.zIndex = '1' + const gridContainer = document.createElement('div'); + gridContainer.id = 'pdfGrid'; + gridContainer.className = 'absolute inset-0 pointer-events-none'; + gridContainer.style.zIndex = '1'; - if (gridV > 0) { - const stepX = canvas.offsetWidth / gridV - for (let i = 0; i <= gridV; i++) { - const line = document.createElement('div') - line.className = 'absolute top-0 bottom-0 border-l-2 border-indigo-500 opacity-60' - line.style.left = (i * stepX) + 'px' - gridContainer.appendChild(line) - } + if (gridV > 0) { + const stepX = canvas.offsetWidth / gridV; + for (let i = 0; i <= gridV; i++) { + const line = document.createElement('div'); + line.className = + 'absolute top-0 bottom-0 border-l-2 border-indigo-500 opacity-60'; + line.style.left = i * stepX + 'px'; + gridContainer.appendChild(line); } + } - if (gridH > 0) { - const stepY = canvas.offsetHeight / gridH - for (let i = 0; i <= gridH; i++) { - const line = document.createElement('div') - line.className = 'absolute left-0 right-0 border-t-2 border-indigo-500 opacity-60' - line.style.top = (i * stepY) + 'px' - gridContainer.appendChild(line) - } + if (gridH > 0) { + const stepY = canvas.offsetHeight / gridH; + for (let i = 0; i <= gridH; i++) { + const line = document.createElement('div'); + line.className = + 'absolute left-0 right-0 border-t-2 border-indigo-500 opacity-60'; + line.style.top = i * stepY + 'px'; + gridContainer.appendChild(line); } + } - canvas.insertBefore(gridContainer, canvas.firstChild) + canvas.insertBefore(gridContainer, canvas.firstChild); } function removeGrid() { - const existingGrid = document.getElementById('pdfGrid') - if (existingGrid) existingGrid.remove() + const existingGrid = document.getElementById('pdfGrid'); + if (existingGrid) existingGrid.remove(); } if (gotoPageBtn && gotoPageInput) { - gotoPageBtn.addEventListener('click', () => { - const pageNum = parseInt(gotoPageInput.value) - if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= pages.length) { - currentPageIndex = pageNum - 1 - renderCanvas() - updatePageNavigation() - } else { - alert(`Please enter a valid page number between 1 and ${pages.length}`) - } - }) + gotoPageBtn.addEventListener('click', () => { + const pageNum = parseInt(gotoPageInput.value); + if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= pages.length) { + currentPageIndex = pageNum - 1; + renderCanvas(); + updatePageNavigation(); + } else { + alert(`Please enter a valid page number between 1 and ${pages.length}`); + } + }); - gotoPageInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - gotoPageBtn.click() - } - }) + gotoPageInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + gotoPageBtn.click(); + } + }); } // Tool item interactions -const toolItems = document.querySelectorAll('.tool-item') -toolItems.forEach(item => { - // Drag from toolbar - item.addEventListener('dragstart', (e) => { - if (e instanceof DragEvent && e.dataTransfer) { - e.dataTransfer.effectAllowed = 'copy' - const type = (item as HTMLElement).dataset.type || 'text' - e.dataTransfer.setData('text/plain', type) - if (gridEnabled) renderGrid() - } - }) +const toolItems = document.querySelectorAll('.tool-item'); +toolItems.forEach((item) => { + // Drag from toolbar + item.addEventListener('dragstart', (e) => { + if (e instanceof DragEvent && e.dataTransfer) { + e.dataTransfer.effectAllowed = 'copy'; + const type = (item as HTMLElement).dataset.type || 'text'; + e.dataTransfer.setData('text/plain', type); + if (gridEnabled) renderGrid(); + } + }); - item.addEventListener('dragend', () => { - if (!gridAlwaysVisible && gridEnabled) removeGrid() - }) - item.addEventListener('click', () => { - const type = (item as HTMLElement).dataset.type || 'text' + item.addEventListener('dragend', () => { + if (!gridAlwaysVisible && gridEnabled) removeGrid(); + }); + item.addEventListener('click', () => { + const type = (item as HTMLElement).dataset.type || 'text'; - // Toggle selection - if (selectedToolType === type) { - // Deselect - selectedToolType = null - item.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600') - canvas.style.cursor = 'default' - } else { - // Deselect previous tool - if (selectedToolType) { - toolItems.forEach(t => t.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600')) - } - // Select new tool - selectedToolType = type - item.classList.add('ring-2', 'ring-indigo-400', 'bg-indigo-600') - canvas.style.cursor = 'crosshair' - } - }) + // Toggle selection + if (selectedToolType === type) { + // Deselect + selectedToolType = null; + item.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600'); + canvas.style.cursor = 'default'; + } else { + // Deselect previous tool + if (selectedToolType) { + toolItems.forEach((t) => + t.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600') + ); + } + // Select new tool + selectedToolType = type; + item.classList.add('ring-2', 'ring-indigo-400', 'bg-indigo-600'); + canvas.style.cursor = 'crosshair'; + } + }); - // Touch events for mobile drag - let touchStartX = 0 - let touchStartY = 0 - let isTouchDragging = false + // Touch events for mobile drag + let touchStartX = 0; + let touchStartY = 0; + let isTouchDragging = false; - item.addEventListener('touchstart', (e) => { - const touch = e.touches[0] - touchStartX = touch.clientX - touchStartY = touch.clientY - isTouchDragging = false - }) + item.addEventListener('touchstart', (e) => { + const touch = e.touches[0]; + touchStartX = touch.clientX; + touchStartY = touch.clientY; + isTouchDragging = false; + }); - item.addEventListener('touchmove', (e) => { - e.preventDefault() // Prevent scrolling while dragging - const touch = e.touches[0] - const moveX = Math.abs(touch.clientX - touchStartX) - const moveY = Math.abs(touch.clientY - touchStartY) + item.addEventListener('touchmove', (e) => { + e.preventDefault(); // Prevent scrolling while dragging + const touch = e.touches[0]; + const moveX = Math.abs(touch.clientX - touchStartX); + const moveY = Math.abs(touch.clientY - touchStartY); - // If moved more than 10px, it's a drag not a click - if (moveX > 10 || moveY > 10) { - isTouchDragging = true - } - }) + // If moved more than 10px, it's a drag not a click + if (moveX > 10 || moveY > 10) { + isTouchDragging = true; + } + }); - item.addEventListener('touchend', (e) => { - e.preventDefault() - if (!isTouchDragging) { - // It was a tap, treat as click - (item as HTMLElement).click() - return - } + item.addEventListener('touchend', (e) => { + e.preventDefault(); + if (!isTouchDragging) { + // It was a tap, treat as click + (item as HTMLElement).click(); + return; + } - // It was a drag, place field at touch end position - const touch = e.changedTouches[0] - const canvasRect = canvas.getBoundingClientRect() + // It was a drag, place field at touch end position + const touch = e.changedTouches[0]; + const canvasRect = canvas.getBoundingClientRect(); - // Check if touch ended on canvas - if (touch.clientX >= canvasRect.left && touch.clientX <= canvasRect.right && - touch.clientY >= canvasRect.top && touch.clientY <= canvasRect.bottom) { - const x = touch.clientX - canvasRect.left - 75 - const y = touch.clientY - canvasRect.top - 15 - const type = (item as HTMLElement).dataset.type || 'text' - createField(type as any, x, y) - } - }) -}) + // Check if touch ended on canvas + if ( + touch.clientX >= canvasRect.left && + touch.clientX <= canvasRect.right && + touch.clientY >= canvasRect.top && + touch.clientY <= canvasRect.bottom + ) { + const x = touch.clientX - canvasRect.left - 75; + const y = touch.clientY - canvasRect.top - 15; + const type = (item as HTMLElement).dataset.type || 'text'; + createField(type as any, x, y); + } + }); +}); // Canvas drop zone canvas.addEventListener('dragover', (e) => { - e.preventDefault() - if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'copy' - } -}) + e.preventDefault(); + if (e.dataTransfer) { + e.dataTransfer.dropEffect = 'copy'; + } +}); canvas.addEventListener('drop', (e) => { - e.preventDefault() - if (!gridAlwaysVisible) removeGrid() - const rect = canvas.getBoundingClientRect() - const x = e.clientX - rect.left - 75 - const y = e.clientY - rect.top - 15 - const type = e.dataTransfer?.getData('text/plain') || 'text' - createField(type as any, x, y) -}) + e.preventDefault(); + if (!gridAlwaysVisible) removeGrid(); + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left - 75; + const y = e.clientY - rect.top - 15; + const type = e.dataTransfer?.getData('text/plain') || 'text'; + createField(type as any, x, y); +}); canvas.addEventListener('click', (e) => { - if (selectedToolType) { - const rect = canvas.getBoundingClientRect() - const x = e.clientX - rect.left - 75 - const y = e.clientY - rect.top - 15 - createField(selectedToolType as any, x, y) + if (selectedToolType) { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left - 75; + const y = e.clientY - rect.top - 15; + createField(selectedToolType as any, x, y); - toolItems.forEach(item => item.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600')) - selectedToolType = null - canvas.style.cursor = 'default' - return - } + toolItems.forEach((item) => + item.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600') + ); + selectedToolType = null; + canvas.style.cursor = 'default'; + return; + } - // Existing deselect behavior (only if no tool is selected) - if (e.target === canvas) { - deselectAll() - } -}) + // Existing deselect behavior (only if no tool is selected) + if (e.target === canvas) { + deselectAll(); + } +}); function createField(type: FormField['type'], x: number, y: number): void { - fieldCounter++ - const field: FormField = { - id: `field_${fieldCounter}`, - type: type, - x: Math.max(0, Math.min(x, 816 - 150)), - y: Math.max(0, Math.min(y, 1056 - 30)), - width: type === 'checkbox' || type === 'radio' ? 30 : 150, - height: type === 'checkbox' || type === 'radio' ? 30 : 30, - name: `${type.charAt(0).toUpperCase() + type.slice(1)}_${fieldCounter}`, - defaultValue: '', - fontSize: 12, - alignment: 'left', - textColor: '#000000', - required: false, - readOnly: false, - tooltip: '', - combCells: 0, - maxLength: 0, - options: type === 'dropdown' || type === 'optionlist' ? ['Option 1', 'Option 2', 'Option 3'] : undefined, - checked: type === 'radio' || type === 'checkbox' ? false : undefined, - exportValue: type === 'radio' || type === 'checkbox' ? 'Yes' : undefined, - groupName: type === 'radio' ? 'RadioGroup1' : undefined, - label: type === 'button' ? 'Button' : (type === 'image' ? 'Click to Upload Image' : undefined), - action: type === 'button' ? 'none' : undefined, - jsScript: type === 'button' ? 'app.alert("Hello World!");' : undefined, - visibilityAction: type === 'button' ? 'toggle' : undefined, - dateFormat: type === 'date' ? 'mm/dd/yyyy' : undefined, - pageIndex: currentPageIndex, - multiline: type === 'text' ? false : undefined, - borderColor: '#000000', - hideBorder: false - } + fieldCounter++; + const field: FormField = { + id: `field_${fieldCounter}`, + type: type, + x: Math.max(0, Math.min(x, 816 - 150)), + y: Math.max(0, Math.min(y, 1056 - 30)), + width: type === 'checkbox' || type === 'radio' ? 30 : 150, + height: type === 'checkbox' || type === 'radio' ? 30 : 30, + name: `${type.charAt(0).toUpperCase() + type.slice(1)}_${fieldCounter}`, + defaultValue: '', + fontSize: 12, + alignment: 'left', + textColor: '#000000', + required: false, + readOnly: false, + tooltip: '', + combCells: 0, + maxLength: 0, + options: + type === 'dropdown' || type === 'optionlist' + ? ['Option 1', 'Option 2', 'Option 3'] + : undefined, + checked: type === 'radio' || type === 'checkbox' ? false : undefined, + exportValue: type === 'radio' || type === 'checkbox' ? 'Yes' : undefined, + groupName: type === 'radio' ? 'RadioGroup1' : undefined, + label: + type === 'button' + ? 'Button' + : type === 'image' + ? 'Click to Upload Image' + : undefined, + action: type === 'button' ? 'none' : undefined, + jsScript: type === 'button' ? 'app.alert("Hello World!");' : undefined, + visibilityAction: type === 'button' ? 'toggle' : undefined, + dateFormat: type === 'date' ? 'mm/dd/yyyy' : undefined, + pageIndex: currentPageIndex, + multiline: type === 'text' ? false : undefined, + borderColor: '#000000', + hideBorder: false, + }; - fields.push(field) - renderField(field) - updateFieldCount() + fields.push(field); + renderField(field); + updateFieldCount(); } // Render field on canvas function renderField(field: FormField): void { - const fieldWrapper = document.createElement('div') - fieldWrapper.id = field.id - fieldWrapper.className = 'absolute cursor-move group' // Added group for hover effects - fieldWrapper.style.left = field.x + 'px' - fieldWrapper.style.top = field.y + 'px' - fieldWrapper.style.width = field.width + 'px' - fieldWrapper.style.overflow = 'visible' - fieldWrapper.style.zIndex = '10' // Ensure fields are above grid and PDF + const existingField = document.getElementById(field.id); + if (existingField) { + existingField.remove(); + } - // Create label - hidden by default, shown on group hover or selection - const label = document.createElement('div') - label.className = 'field-label absolute left-0 w-full text-xs font-semibold pointer-events-none select-none opacity-0 group-hover:opacity-100 transition-opacity' - label.style.bottom = '100%' - label.style.marginBottom = '4px' - label.style.color = '#374151' - label.style.fontSize = '11px' - label.style.lineHeight = '1' - label.style.whiteSpace = 'nowrap' - label.style.overflow = 'hidden' - label.style.textOverflow = 'ellipsis' - label.textContent = field.name + const fieldWrapper = document.createElement('div'); + fieldWrapper.id = field.id; + fieldWrapper.className = 'absolute cursor-move group'; // Added group for hover effects + fieldWrapper.style.left = field.x + 'px'; + fieldWrapper.style.top = field.y + 'px'; + fieldWrapper.style.width = field.width + 'px'; + fieldWrapper.style.overflow = 'visible'; + fieldWrapper.style.zIndex = '10'; // Ensure fields are above grid and PDF - // Create input container - light border by default, dashed on hover - const fieldContainer = document.createElement('div') - fieldContainer.className = - 'field-container relative border-2 border-indigo-200 group-hover:border-dashed group-hover:border-indigo-300 bg-indigo-50/30 rounded transition-all' - fieldContainer.style.width = '100%' - fieldContainer.style.height = field.height + 'px' + // Create label - hidden by default, shown on group hover or selection + const label = document.createElement('div'); + label.className = + 'field-label absolute left-0 w-full text-xs font-semibold pointer-events-none select-none opacity-0 group-hover:opacity-100 transition-opacity'; + label.style.bottom = '100%'; + label.style.marginBottom = '4px'; + label.style.color = '#374151'; + label.style.fontSize = '11px'; + label.style.lineHeight = '1'; + label.style.whiteSpace = 'nowrap'; + label.style.overflow = 'hidden'; + label.style.textOverflow = 'ellipsis'; + label.textContent = field.name; - // Create content based on type - const contentEl = document.createElement('div') - contentEl.className = 'field-content w-full h-full flex items-center justify-center overflow-hidden' + // Create input container - light border by default, dashed on hover + const fieldContainer = document.createElement('div'); + fieldContainer.className = + 'field-container relative border-2 border-indigo-200 group-hover:border-dashed group-hover:border-indigo-300 bg-indigo-50/30 rounded transition-all'; + fieldContainer.style.width = '100%'; + fieldContainer.style.height = field.height + 'px'; - if (field.type === 'text') { - contentEl.className = 'field-text w-full h-full flex items-center px-2 text-sm overflow-hidden' - contentEl.style.fontSize = field.fontSize + 'px' - contentEl.style.textAlign = field.alignment - contentEl.style.justifyContent = field.alignment === 'left' ? 'flex-start' : field.alignment === 'right' ? 'flex-end' : 'center' - contentEl.style.color = field.textColor - contentEl.style.whiteSpace = field.multiline ? 'pre-wrap' : 'nowrap' - contentEl.style.textOverflow = 'ellipsis' - contentEl.style.alignItems = field.multiline ? 'flex-start' : 'center' - contentEl.textContent = field.defaultValue + // Create content based on type + const contentEl = document.createElement('div'); + contentEl.className = + 'field-content w-full h-full flex items-center justify-center overflow-hidden'; - // Apply combing visual if enabled - if (field.combCells > 0) { - contentEl.style.backgroundImage = `repeating-linear-gradient(90deg, transparent, transparent calc((100% / ${field.combCells}) - 1px), #e5e7eb calc((100% / ${field.combCells}) - 1px), #e5e7eb calc(100% / ${field.combCells}))` - contentEl.style.fontFamily = 'monospace' - contentEl.style.letterSpacing = `calc(${field.width / field.combCells}px - 1ch)` - contentEl.style.paddingLeft = `calc((${field.width / field.combCells}px - 1ch) / 2)` - contentEl.style.overflow = 'hidden' - contentEl.style.textAlign = 'left' - contentEl.style.justifyContent = 'flex-start' - } - } else if (field.type === 'checkbox') { - contentEl.innerHTML = field.checked ? '' : '' - } else if (field.type === 'radio') { - fieldContainer.classList.add('rounded-full') // Make container round for radio - contentEl.innerHTML = field.checked ? '
' : '' - } else if (field.type === 'dropdown') { - contentEl.className = 'w-full h-full flex items-center px-2 text-sm text-black' - contentEl.style.backgroundColor = '#e6f0ff' // Light blue background like Firefox + if (field.type === 'text') { + contentEl.className = + 'field-text w-full h-full flex items-center px-2 text-sm overflow-hidden'; + contentEl.style.fontSize = field.fontSize + 'px'; + contentEl.style.textAlign = field.alignment; + contentEl.style.justifyContent = + field.alignment === 'left' + ? 'flex-start' + : field.alignment === 'right' + ? 'flex-end' + : 'center'; + contentEl.style.color = field.textColor; + contentEl.style.whiteSpace = field.multiline ? 'pre-wrap' : 'nowrap'; + contentEl.style.textOverflow = 'ellipsis'; + contentEl.style.alignItems = field.multiline ? 'flex-start' : 'center'; + contentEl.textContent = field.defaultValue; - // Show selected option or first option or placeholder - let displayText = 'Select...' - if (field.defaultValue && field.options && field.options.includes(field.defaultValue)) { - displayText = field.defaultValue - } else if (field.options && field.options.length > 0) { - displayText = field.options[0] - } - contentEl.textContent = displayText - - const arrow = document.createElement('div') - arrow.className = 'absolute right-1 top-1/2 -translate-y-1/2' - arrow.innerHTML = '' - fieldContainer.appendChild(arrow) - - } else if (field.type === 'optionlist') { - contentEl.className = 'w-full h-full flex flex-col text-sm bg-white overflow-hidden border border-gray-300' - // Render options as a list - if (field.options && field.options.length > 0) { - field.options.forEach((opt, index) => { - const optEl = document.createElement('div') - optEl.className = 'px-1 w-full truncate' - optEl.textContent = opt - - // Highlight selected option (defaultValue) or first one if no selection - const isSelected = field.defaultValue ? field.defaultValue === opt : index === 0 - - if (isSelected) { - optEl.className += ' bg-blue-600 text-white' - } else { - optEl.className += ' text-black' - } - contentEl.appendChild(optEl) - }) - } else { - // Empty state - const optEl = document.createElement('div') - optEl.className = 'px-1 w-full text-black italic' - optEl.textContent = 'Item 1' - contentEl.appendChild(optEl) - } - - } else if (field.type === 'button') { - contentEl.className = 'field-content w-full h-full flex items-center justify-center bg-gray-200 text-sm font-semibold' - contentEl.style.color = field.textColor || '#000000' - contentEl.textContent = field.label || 'Button' - } else if (field.type === 'signature') { - contentEl.className = 'w-full h-full flex items-center justify-center bg-gray-50 text-gray-400' - contentEl.innerHTML = '
Sign Here
' - setTimeout(() => (window as any).lucide?.createIcons(), 0) - } else if (field.type === 'date') { - contentEl.className = 'w-full h-full flex items-center justify-center bg-white text-gray-600 border border-gray-300' - contentEl.innerHTML = `
${field.dateFormat || 'mm/dd/yyyy'}
` - setTimeout(() => (window as any).lucide?.createIcons(), 0) - } else if (field.type === 'image') { - contentEl.className = 'w-full h-full flex items-center justify-center bg-gray-100 text-gray-500 border border-gray-300' - contentEl.innerHTML = `
${field.label || 'Click to Upload Image'}
` - setTimeout(() => (window as any).lucide?.createIcons(), 0) + // Apply combing visual if enabled + if (field.combCells > 0) { + contentEl.style.backgroundImage = `repeating-linear-gradient(90deg, transparent, transparent calc((100% / ${field.combCells}) - 1px), #e5e7eb calc((100% / ${field.combCells}) - 1px), #e5e7eb calc(100% / ${field.combCells}))`; + contentEl.style.fontFamily = 'monospace'; + contentEl.style.letterSpacing = `calc(${field.width / field.combCells}px - 1ch)`; + contentEl.style.paddingLeft = `calc((${field.width / field.combCells}px - 1ch) / 2)`; + contentEl.style.overflow = 'hidden'; + contentEl.style.textAlign = 'left'; + contentEl.style.justifyContent = 'flex-start'; } + } else if (field.type === 'checkbox') { + contentEl.innerHTML = field.checked + ? '' + : ''; + } else if (field.type === 'radio') { + fieldContainer.classList.add('rounded-full'); // Make container round for radio + contentEl.innerHTML = field.checked + ? '
' + : ''; + } else if (field.type === 'dropdown') { + contentEl.className = + 'w-full h-full flex items-center px-2 text-sm text-black'; + contentEl.style.backgroundColor = '#e6f0ff'; // Light blue background like Firefox - fieldContainer.appendChild(contentEl) - fieldWrapper.appendChild(label) - fieldWrapper.appendChild(fieldContainer) + // Show selected option or first option or placeholder + let displayText = 'Select...'; + if ( + field.defaultValue && + field.options && + field.options.includes(field.defaultValue) + ) { + displayText = field.defaultValue; + } else if (field.options && field.options.length > 0) { + displayText = field.options[0]; + } + contentEl.textContent = displayText; - // Click to select - fieldWrapper.addEventListener('click', (e) => { - e.stopPropagation() - selectField(field) - }) + const arrow = document.createElement('div'); + arrow.className = 'absolute right-1 top-1/2 -translate-y-1/2'; + arrow.innerHTML = + ''; + fieldContainer.appendChild(arrow); + } else if (field.type === 'optionlist') { + contentEl.className = + 'w-full h-full flex flex-col text-sm bg-white overflow-hidden border border-gray-300'; + // Render options as a list + if (field.options && field.options.length > 0) { + field.options.forEach((opt, index) => { + const optEl = document.createElement('div'); + optEl.className = 'px-1 w-full truncate'; + optEl.textContent = opt; - // Drag to move - fieldWrapper.addEventListener('mousedown', (e) => { - // Don't start drag if clicking on a resize handle - if ((e.target as HTMLElement).classList.contains('resize-handle')) { - return + // Highlight selected option (defaultValue) or first one if no selection + const isSelected = field.defaultValue + ? field.defaultValue === opt + : index === 0; + + if (isSelected) { + optEl.className += ' bg-blue-600 text-white'; + } else { + optEl.className += ' text-black'; } - draggedElement = fieldWrapper - const rect = canvas.getBoundingClientRect() - offsetX = e.clientX - rect.left - field.x - offsetY = e.clientY - rect.top - field.y - selectField(field) - if (gridEnabled) renderGrid() - e.preventDefault() - }) + contentEl.appendChild(optEl); + }); + } else { + // Empty state + const optEl = document.createElement('div'); + optEl.className = 'px-1 w-full text-black italic'; + optEl.textContent = 'Item 1'; + contentEl.appendChild(optEl); + } + } else if (field.type === 'button') { + contentEl.className = + 'field-content w-full h-full flex items-center justify-center bg-gray-200 text-sm font-semibold'; + contentEl.style.color = field.textColor || '#000000'; + contentEl.textContent = field.label || 'Button'; + } else if (field.type === 'signature') { + contentEl.className = + 'w-full h-full flex items-center justify-center bg-gray-50 text-gray-400'; + contentEl.innerHTML = + '
Sign Here
'; + setTimeout(() => (window as any).lucide?.createIcons(), 0); + } else if (field.type === 'date') { + contentEl.className = + 'w-full h-full flex items-center justify-center bg-white text-gray-600 border border-gray-300'; + contentEl.innerHTML = `
${field.dateFormat || 'mm/dd/yyyy'}
`; + setTimeout(() => (window as any).lucide?.createIcons(), 0); + } else if (field.type === 'image') { + contentEl.className = + 'w-full h-full flex items-center justify-center bg-gray-100 text-gray-500 border border-gray-300'; + contentEl.innerHTML = `
${field.label || 'Click to Upload Image'}
`; + setTimeout(() => (window as any).lucide?.createIcons(), 0); + } - // Touch events for moving fields - let touchMoveStarted = false - fieldWrapper.addEventListener('touchstart', (e) => { - if ((e.target as HTMLElement).classList.contains('resize-handle')) { - return - } - touchMoveStarted = false - const touch = e.touches[0] - const rect = canvas.getBoundingClientRect() - offsetX = touch.clientX - rect.left - field.x - offsetY = touch.clientY - rect.top - field.y - selectField(field) - }, { passive: true }) + fieldContainer.appendChild(contentEl); + fieldWrapper.appendChild(label); + fieldWrapper.appendChild(fieldContainer); - fieldWrapper.addEventListener('touchmove', (e) => { - e.preventDefault() - touchMoveStarted = true - const touch = e.touches[0] - const rect = canvas.getBoundingClientRect() - let newX = touch.clientX - rect.left - offsetX - let newY = touch.clientY - rect.top - offsetY + // Click to select + fieldWrapper.addEventListener('click', (e) => { + e.stopPropagation(); + selectField(field); + }); - newX = Math.max(0, Math.min(newX, rect.width - fieldWrapper.offsetWidth)) - newY = Math.max(0, Math.min(newY, rect.height - fieldWrapper.offsetHeight)) + // Drag to move + fieldWrapper.addEventListener('mousedown', (e) => { + // Don't start drag if clicking on a resize handle + if ((e.target as HTMLElement).classList.contains('resize-handle')) { + return; + } + draggedElement = fieldWrapper; + const rect = canvas.getBoundingClientRect(); + offsetX = e.clientX - rect.left - field.x; + offsetY = e.clientY - rect.top - field.y; + selectField(field); + if (gridEnabled) renderGrid(); + e.preventDefault(); + }); - fieldWrapper.style.left = newX + 'px' - fieldWrapper.style.top = newY + 'px' + // Touch events for moving fields + let touchMoveStarted = false; + fieldWrapper.addEventListener( + 'touchstart', + (e) => { + if ((e.target as HTMLElement).classList.contains('resize-handle')) { + return; + } + touchMoveStarted = false; + const touch = e.touches[0]; + const rect = canvas.getBoundingClientRect(); + offsetX = touch.clientX - rect.left - field.x; + offsetY = touch.clientY - rect.top - field.y; + selectField(field); + }, + { passive: true } + ); - field.x = newX - field.y = newY - }) + fieldWrapper.addEventListener('touchmove', (e) => { + e.preventDefault(); + touchMoveStarted = true; + const touch = e.touches[0]; + const rect = canvas.getBoundingClientRect(); + let newX = touch.clientX - rect.left - offsetX; + let newY = touch.clientY - rect.top - offsetY; - fieldWrapper.addEventListener('touchend', () => { - touchMoveStarted = false - }) + newX = Math.max(0, Math.min(newX, rect.width - fieldWrapper.offsetWidth)); + newY = Math.max(0, Math.min(newY, rect.height - fieldWrapper.offsetHeight)); - // Add resize handles to the container - hidden by default - const handles = ['nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w'] - handles.forEach((pos) => { - const handle = document.createElement('div') - handle.className = `absolute w-2.5 h-2.5 bg-white border border-indigo-600 z-10 cursor-${pos}-resize resize-handle hidden` // Added hidden class - const positions: Record = { - nw: 'top-0 left-0 -translate-x-1/2 -translate-y-1/2', - ne: 'top-0 right-0 translate-x-1/2 -translate-y-1/2', - sw: 'bottom-0 left-0 -translate-x-1/2 translate-y-1/2', - se: 'bottom-0 right-0 translate-x-1/2 translate-y-1/2', - n: 'top-0 left-1/2 -translate-x-1/2 -translate-y-1/2', - s: 'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2', - e: 'top-1/2 right-0 translate-x-1/2 -translate-y-1/2', - w: 'top-1/2 left-0 -translate-x-1/2 -translate-y-1/2', - } - handle.className += ` ${positions[pos]}` - handle.dataset.position = pos + fieldWrapper.style.left = newX + 'px'; + fieldWrapper.style.top = newY + 'px'; - handle.addEventListener('mousedown', (e) => { - e.stopPropagation() - startResize(e, field, pos) - }) + field.x = newX; + field.y = newY; + }); - // Touch events for resize handles - handle.addEventListener('touchstart', (e) => { - e.stopPropagation() - e.preventDefault() - const touch = e.touches[0] - // Create a synthetic mouse event for startResize - const syntheticEvent = { - clientX: touch.clientX, - clientY: touch.clientY, - preventDefault: () => { } - } as MouseEvent - startResize(syntheticEvent, field, pos) - }) + fieldWrapper.addEventListener('touchend', () => { + touchMoveStarted = false; + }); - fieldContainer.appendChild(handle) - }) + // Add resize handles to the container - hidden by default + const handles = ['nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w']; + handles.forEach((pos) => { + const handle = document.createElement('div'); + handle.className = `absolute w-2.5 h-2.5 bg-white border border-indigo-600 z-10 cursor-${pos}-resize resize-handle hidden`; // Added hidden class + const positions: Record = { + nw: 'top-0 left-0 -translate-x-1/2 -translate-y-1/2', + ne: 'top-0 right-0 translate-x-1/2 -translate-y-1/2', + sw: 'bottom-0 left-0 -translate-x-1/2 translate-y-1/2', + se: 'bottom-0 right-0 translate-x-1/2 translate-y-1/2', + n: 'top-0 left-1/2 -translate-x-1/2 -translate-y-1/2', + s: 'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2', + e: 'top-1/2 right-0 translate-x-1/2 -translate-y-1/2', + w: 'top-1/2 left-0 -translate-x-1/2 -translate-y-1/2', + }; + handle.className += ` ${positions[pos]}`; + handle.dataset.position = pos; - canvas.appendChild(fieldWrapper) + handle.addEventListener('mousedown', (e) => { + e.stopPropagation(); + startResize(e, field, pos); + }); + + // Touch events for resize handles + handle.addEventListener('touchstart', (e) => { + e.stopPropagation(); + e.preventDefault(); + const touch = e.touches[0]; + // Create a synthetic mouse event for startResize + const syntheticEvent = { + clientX: touch.clientX, + clientY: touch.clientY, + preventDefault: () => {}, + } as MouseEvent; + startResize(syntheticEvent, field, pos); + }); + + fieldContainer.appendChild(handle); + }); + + canvas.appendChild(fieldWrapper); } function startResize(e: MouseEvent, field: FormField, pos: string): void { - resizing = true - resizeField = field - resizePos = pos - startX = e.clientX - startY = e.clientY - startWidth = field.width - startHeight = field.height - startLeft = field.x - startTop = field.y - e.preventDefault() + resizing = true; + resizeField = field; + resizePos = pos; + startX = e.clientX; + startY = e.clientY; + startWidth = field.width; + startHeight = field.height; + startLeft = field.x; + startTop = field.y; + e.preventDefault(); } // Mouse move for dragging and resizing document.addEventListener('mousemove', (e) => { - if (draggedElement && !resizing) { - const rect = canvas.getBoundingClientRect() - let newX = e.clientX - rect.left - offsetX - let newY = e.clientY - rect.top - offsetY + if (draggedElement && !resizing) { + const rect = canvas.getBoundingClientRect(); + let newX = e.clientX - rect.left - offsetX; + let newY = e.clientY - rect.top - offsetY; - newX = Math.max(0, Math.min(newX, rect.width - draggedElement.offsetWidth)) - newY = Math.max(0, Math.min(newY, rect.height - draggedElement.offsetHeight)) + newX = Math.max(0, Math.min(newX, rect.width - draggedElement.offsetWidth)); + newY = Math.max( + 0, + Math.min(newY, rect.height - draggedElement.offsetHeight) + ); - draggedElement.style.left = newX + 'px' - draggedElement.style.top = newY + 'px' + draggedElement.style.left = newX + 'px'; + draggedElement.style.top = newY + 'px'; - const field = fields.find((f) => f.id === draggedElement!.id) - if (field) { - field.x = newX - field.y = newY - } - } else if (resizing && resizeField) { - const dx = e.clientX - startX - const dy = e.clientY - startY - const fieldWrapper = document.getElementById(resizeField.id) - - if (resizePos!.includes('e')) { - resizeField.width = Math.max(50, startWidth + dx) - } - if (resizePos!.includes('w')) { - const newWidth = Math.max(50, startWidth - dx) - const widthDiff = startWidth - newWidth - resizeField.width = newWidth - resizeField.x = startLeft + widthDiff - } - if (resizePos!.includes('s')) { - resizeField.height = Math.max(20, startHeight + dy) - } - if (resizePos!.includes('n')) { - const newHeight = Math.max(20, startHeight - dy) - const heightDiff = startHeight - newHeight - resizeField.height = newHeight - resizeField.y = startTop + heightDiff - } - - if (fieldWrapper) { - const container = fieldWrapper.querySelector('.field-container') as HTMLElement - fieldWrapper.style.width = resizeField.width + 'px' - fieldWrapper.style.left = resizeField.x + 'px' - fieldWrapper.style.top = resizeField.y + 'px' - if (container) { - container.style.height = resizeField.height + 'px' - } - // Update combing visuals on resize - if (resizeField.combCells > 0) { - const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement - if (textEl) { - textEl.style.letterSpacing = `calc(${resizeField.width / resizeField.combCells}px - 1ch)` - textEl.style.paddingLeft = `calc((${resizeField.width / resizeField.combCells}px - 1ch) / 2)` - } - } - } + const field = fields.find((f) => f.id === draggedElement!.id); + if (field) { + field.x = newX; + field.y = newY; } -}) + } else if (resizing && resizeField) { + const dx = e.clientX - startX; + const dy = e.clientY - startY; + const fieldWrapper = document.getElementById(resizeField.id); + + if (resizePos!.includes('e')) { + resizeField.width = Math.max(50, startWidth + dx); + } + if (resizePos!.includes('w')) { + const newWidth = Math.max(50, startWidth - dx); + const widthDiff = startWidth - newWidth; + resizeField.width = newWidth; + resizeField.x = startLeft + widthDiff; + } + if (resizePos!.includes('s')) { + resizeField.height = Math.max(20, startHeight + dy); + } + if (resizePos!.includes('n')) { + const newHeight = Math.max(20, startHeight - dy); + const heightDiff = startHeight - newHeight; + resizeField.height = newHeight; + resizeField.y = startTop + heightDiff; + } + + if (fieldWrapper) { + const container = fieldWrapper.querySelector( + '.field-container' + ) as HTMLElement; + fieldWrapper.style.width = resizeField.width + 'px'; + fieldWrapper.style.left = resizeField.x + 'px'; + fieldWrapper.style.top = resizeField.y + 'px'; + if (container) { + container.style.height = resizeField.height + 'px'; + } + // Update combing visuals on resize + if (resizeField.combCells > 0) { + const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement; + if (textEl) { + textEl.style.letterSpacing = `calc(${resizeField.width / resizeField.combCells}px - 1ch)`; + textEl.style.paddingLeft = `calc((${resizeField.width / resizeField.combCells}px - 1ch) / 2)`; + } + } + } + } +}); document.addEventListener('mouseup', () => { - draggedElement = null - resizing = false - resizeField = null - if (!gridAlwaysVisible) removeGrid() -}) + draggedElement = null; + resizing = false; + resizeField = null; + if (!gridAlwaysVisible) removeGrid(); +}); -document.addEventListener('touchmove', (e) => { - const touch = e.touches[0] +document.addEventListener( + 'touchmove', + (e) => { + const touch = e.touches[0]; if (resizing && resizeField) { - const dx = touch.clientX - startX - const dy = touch.clientY - startY - const fieldWrapper = document.getElementById(resizeField.id) + const dx = touch.clientX - startX; + const dy = touch.clientY - startY; + const fieldWrapper = document.getElementById(resizeField.id); - if (resizePos!.includes('e')) { - resizeField.width = Math.max(50, startWidth + dx) - } - if (resizePos!.includes('w')) { - const newWidth = Math.max(50, startWidth - dx) - const widthDiff = startWidth - newWidth - resizeField.width = newWidth - resizeField.x = startLeft + widthDiff - } - if (resizePos!.includes('s')) { - resizeField.height = Math.max(20, startHeight + dy) - } - if (resizePos!.includes('n')) { - const newHeight = Math.max(20, startHeight - dy) - const heightDiff = startHeight - newHeight - resizeField.height = newHeight - resizeField.y = startTop + heightDiff - } + if (resizePos!.includes('e')) { + resizeField.width = Math.max(50, startWidth + dx); + } + if (resizePos!.includes('w')) { + const newWidth = Math.max(50, startWidth - dx); + const widthDiff = startWidth - newWidth; + resizeField.width = newWidth; + resizeField.x = startLeft + widthDiff; + } + if (resizePos!.includes('s')) { + resizeField.height = Math.max(20, startHeight + dy); + } + if (resizePos!.includes('n')) { + const newHeight = Math.max(20, startHeight - dy); + const heightDiff = startHeight - newHeight; + resizeField.height = newHeight; + resizeField.y = startTop + heightDiff; + } - if (fieldWrapper) { - const container = fieldWrapper.querySelector('.field-container') as HTMLElement - fieldWrapper.style.width = resizeField.width + 'px' - fieldWrapper.style.left = resizeField.x + 'px' - fieldWrapper.style.top = resizeField.y + 'px' - if (container) { - container.style.height = resizeField.height + 'px' - } - if (resizeField.combCells > 0) { - const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement - if (textEl) { - textEl.style.letterSpacing = `calc(${resizeField.width / resizeField.combCells}px - 1ch)` - textEl.style.paddingLeft = `calc((${resizeField.width / resizeField.combCells}px - 1ch) / 2)` - } - } + if (fieldWrapper) { + const container = fieldWrapper.querySelector( + '.field-container' + ) as HTMLElement; + fieldWrapper.style.width = resizeField.width + 'px'; + fieldWrapper.style.left = resizeField.x + 'px'; + fieldWrapper.style.top = resizeField.y + 'px'; + if (container) { + container.style.height = resizeField.height + 'px'; } + if (resizeField.combCells > 0) { + const textEl = fieldWrapper.querySelector( + '.field-text' + ) as HTMLElement; + if (textEl) { + textEl.style.letterSpacing = `calc(${resizeField.width / resizeField.combCells}px - 1ch)`; + textEl.style.paddingLeft = `calc((${resizeField.width / resizeField.combCells}px - 1ch) / 2)`; + } + } + } } -}, { passive: false }) + }, + { passive: false } +); document.addEventListener('touchend', () => { - resizing = false - resizeField = null -}) - - + resizing = false; + resizeField = null; +}); // Select field function selectField(field: FormField): void { - deselectAll() - selectedField = field - const fieldWrapper = document.getElementById(field.id) - if (fieldWrapper) { - const container = fieldWrapper.querySelector('.field-container') as HTMLElement - const label = fieldWrapper.querySelector('.field-label') as HTMLElement - const handles = fieldWrapper.querySelectorAll('.resize-handle') + deselectAll(); + selectedField = field; + const fieldWrapper = document.getElementById(field.id); + if (fieldWrapper) { + const container = fieldWrapper.querySelector( + '.field-container' + ) as HTMLElement; + const label = fieldWrapper.querySelector('.field-label') as HTMLElement; + const handles = fieldWrapper.querySelectorAll('.resize-handle'); - if (container) { - // Remove hover classes and add selected classes - container.classList.remove('border-indigo-200', 'group-hover:border-dashed', 'group-hover:border-indigo-300') - container.classList.add('border-dashed', 'border-indigo-500', 'bg-indigo-50') - } - - if (label) { - label.classList.remove('opacity-0', 'group-hover:opacity-100') - label.classList.add('opacity-100') - } - - handles.forEach(handle => { - handle.classList.remove('hidden') - }) + if (container) { + // Remove hover classes and add selected classes + container.classList.remove( + 'border-indigo-200', + 'group-hover:border-dashed', + 'group-hover:border-indigo-300' + ); + container.classList.add( + 'border-dashed', + 'border-indigo-500', + 'bg-indigo-50' + ); } - showProperties(field) + + if (label) { + label.classList.remove('opacity-0', 'group-hover:opacity-100'); + label.classList.add('opacity-100'); + } + + handles.forEach((handle) => { + handle.classList.remove('hidden'); + }); + } + showProperties(field); } // Deselect all function deselectAll(): void { - if (selectedField) { - const fieldWrapper = document.getElementById(selectedField.id) - if (fieldWrapper) { - const container = fieldWrapper.querySelector('.field-container') as HTMLElement - const label = fieldWrapper.querySelector('.field-label') as HTMLElement - const handles = fieldWrapper.querySelectorAll('.resize-handle') + if (selectedField) { + const fieldWrapper = document.getElementById(selectedField.id); + if (fieldWrapper) { + const container = fieldWrapper.querySelector( + '.field-container' + ) as HTMLElement; + const label = fieldWrapper.querySelector('.field-label') as HTMLElement; + const handles = fieldWrapper.querySelectorAll('.resize-handle'); - if (container) { - // Revert to default/hover state - container.classList.remove('border-dashed', 'border-indigo-500', 'bg-indigo-50') - container.classList.add('border-indigo-200', 'group-hover:border-dashed', 'group-hover:border-indigo-300') - } + if (container) { + // Revert to default/hover state + container.classList.remove( + 'border-dashed', + 'border-indigo-500', + 'bg-indigo-50' + ); + container.classList.add( + 'border-indigo-200', + 'group-hover:border-dashed', + 'group-hover:border-indigo-300' + ); + } - if (label) { - label.classList.remove('opacity-100') - label.classList.add('opacity-0', 'group-hover:opacity-100') - } + if (label) { + label.classList.remove('opacity-100'); + label.classList.add('opacity-0', 'group-hover:opacity-100'); + } - handles.forEach(handle => { - handle.classList.add('hidden') - }) - } - selectedField = null + handles.forEach((handle) => { + handle.classList.add('hidden'); + }); } - hideProperties() + selectedField = null; + } + hideProperties(); } // Show properties panel function showProperties(field: FormField): void { - let specificProps = '' + let specificProps = ''; - if (field.type === 'text') { - specificProps = ` + if (field.type === 'text') { + specificProps = `
0 ? `maxlength="${field.combCells}"` : field.maxLength > 0 ? `maxlength="${field.maxLength}"` : ''} class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500"> @@ -809,18 +947,18 @@ function showProperties(field: FormField): void {
- ` - } else if (field.type === 'checkbox') { - specificProps = ` + `; + } else if (field.type === 'checkbox') { + specificProps = `
- ` - } else if (field.type === 'radio') { - specificProps = ` + `; + } else if (field.type === 'radio') { + specificProps = `
@@ -835,9 +973,9 @@ function showProperties(field: FormField): void {
- ` - } else if (field.type === 'dropdown' || field.type === 'optionlist') { - specificProps = ` + `; + } else if (field.type === 'dropdown' || field.type === 'optionlist') { + specificProps = `
@@ -846,15 +984,15 @@ function showProperties(field: FormField): void {
To actually fill or change the options, use our PDF Form Filler tool.
- ` - } else if (field.type === 'button') { - specificProps = ` + `; + } else if (field.type === 'button') { + specificProps = `
@@ -883,7 +1021,13 @@ function showProperties(field: FormField): void {
@@ -895,34 +1039,73 @@ function showProperties(field: FormField): void {
- ` - } else if (field.type === 'signature') { - specificProps = ` + `; + } else if (field.type === 'signature') { + specificProps = `
Signature fields are AcroForm signature fields and would only be visible in an advanced PDF viewer.
- ` - } else if (field.type === 'date') { - const formats = ['mm/dd/yyyy', 'dd/mm/yyyy', 'mm/yy', 'dd/mm/yy', 'yyyy/mm/dd', 'mmm d, yyyy', 'd-mmm-yy', 'yy-mm-dd'] - specificProps = ` + `; + } else if (field.type === 'date') { + const formats = [ + 'm/d', + 'm/d/yy', + 'm/d/yyyy', + 'mm/dd/yy', + 'mm/dd/yyyy', + 'mm/yy', + 'mm/yyyy', + 'd-mmm', + 'd-mmm-yy', + 'd-mmm-yyyy', + 'dd-mmm-yy', + 'dd-mmm-yyyy', + 'yy-mm-dd', + 'yyyy-mm-dd', + 'mmm-yy', + 'mmm-yyyy', + 'mmm d, yyyy', + 'mmmm-yy', + 'mmmm-yyyy', + 'mmmm d, yyyy', + 'dd/mm/yy', + 'dd/mm/yyyy', + 'yyyy/mm/dd', + 'dd.mm.yy', + 'dd.mm.yyyy', + 'm/d/yy h:MM tt', + 'm/d/yyyy h:MM tt', + 'm/d/yy HH:MM', + 'm/d/yyyy HH:MM', + 'yyyy-mm', + 'yyyy', + ]; + const isCustom = !formats.includes(field.dateFormat || 'mm/dd/yyyy'); + specificProps = `
-
- The selected format will be enforced when the user types or picks a date. +
+ + +
+
+ Example of current format: +
-

- - Browser Note: Firefox and Chrome may show their native date picker format during selection. The correct format will apply when you finish entering the date. This is normal browser behavior and not an issue. +

+ + Browser Note: Firefox and Chrome may show their native date picker format during selection. The correct format will apply when you finish entering the date.

- ` - } else if (field.type === 'image') { - specificProps = ` + `; + } else if (field.type === 'image') { + specificProps = `
@@ -930,27 +1113,47 @@ function showProperties(field: FormField): void {
Clicking this field in the PDF will open a file picker to upload an image.
- ` - } + `; + } - propertiesPanel.innerHTML = ` + propertiesPanel.innerHTML = `
- ${field.type === 'radio' && (existingRadioGroups.size > 0 || fields.some(f => f.type === 'radio' && f.id !== field.id)) ? ` + ${ + field.type === 'radio' && + (existingRadioGroups.size > 0 || + fields.some((f) => f.type === 'radio' && f.id !== field.id)) + ? `

Select to add this button to an existing group

- ` : ''} + ` + : '' + } ${specificProps}
@@ -976,1113 +1179,1337 @@ function showProperties(field: FormField): void { Delete Field
- ` + `; - // Common listeners - const propName = document.getElementById('propName') as HTMLInputElement - const nameError = document.getElementById('nameError') as HTMLDivElement - const propTooltip = document.getElementById('propTooltip') as HTMLInputElement - const propRequired = document.getElementById('propRequired') as HTMLInputElement - const propReadOnly = document.getElementById('propReadOnly') as HTMLInputElement - const deleteBtn = document.getElementById('deleteBtn') as HTMLButtonElement + // Common listeners + const propName = document.getElementById('propName') as HTMLInputElement; + const nameError = document.getElementById('nameError') as HTMLDivElement; + const propTooltip = document.getElementById( + 'propTooltip' + ) as HTMLInputElement; + const propRequired = document.getElementById( + 'propRequired' + ) as HTMLInputElement; + const propReadOnly = document.getElementById( + 'propReadOnly' + ) as HTMLInputElement; + const deleteBtn = document.getElementById('deleteBtn') as HTMLButtonElement; - const validateName = (newName: string): boolean => { - if (!newName) { - nameError.textContent = 'Field name cannot be empty' - nameError.classList.remove('hidden') - propName.classList.add('border-red-500') - return false - } - - if (field.type === 'radio') { - nameError.classList.add('hidden') - propName.classList.remove('border-red-500') - return true - } - - const isDuplicateInFields = fields.some(f => f.id !== field.id && f.name === newName) - const isDuplicateInPdf = existingFieldNames.has(newName) - - if (isDuplicateInFields || isDuplicateInPdf) { - nameError.textContent = `Field name "${newName}" already exists in this ${isDuplicateInPdf ? 'PDF' : 'form'}. Please try using a unique name.` - nameError.classList.remove('hidden') - propName.classList.add('border-red-500') - return false - } - - nameError.classList.add('hidden') - propName.classList.remove('border-red-500') - return true + const validateName = (newName: string): boolean => { + if (!newName) { + nameError.textContent = 'Field name cannot be empty'; + nameError.classList.remove('hidden'); + propName.classList.add('border-red-500'); + return false; } - propName.addEventListener('input', (e) => { - const newName = (e.target as HTMLInputElement).value.trim() - validateName(newName) - }) - - propName.addEventListener('change', (e) => { - const newName = (e.target as HTMLInputElement).value.trim() - - if (!validateName(newName)) { - (e.target as HTMLInputElement).value = field.name - return - } - - field.name = newName - const fieldWrapper = document.getElementById(field.id) - if (fieldWrapper) { - const label = fieldWrapper.querySelector('.field-label') as HTMLElement - if (label) label.textContent = field.name - } - }) - - propTooltip.addEventListener('input', (e) => { - field.tooltip = (e.target as HTMLInputElement).value - }) - if (field.type === 'radio') { - const existingGroupsSelect = document.getElementById('existingGroups') as HTMLSelectElement - if (existingGroupsSelect) { - existingGroupsSelect.addEventListener('change', (e) => { - const selectedGroup = (e.target as HTMLSelectElement).value - if (selectedGroup) { - propName.value = selectedGroup - field.name = selectedGroup - validateName(selectedGroup) - - // Update field label - const fieldWrapper = document.getElementById(field.id) - if (fieldWrapper) { - const label = fieldWrapper.querySelector('.field-label') as HTMLElement - if (label) label.textContent = field.name - } - } - }) - } + nameError.classList.add('hidden'); + propName.classList.remove('border-red-500'); + return true; } - propRequired.addEventListener('change', (e) => { - field.required = (e.target as HTMLInputElement).checked - }) + const isDuplicateInFields = fields.some( + (f) => f.id !== field.id && f.name === newName + ); + const isDuplicateInPdf = existingFieldNames.has(newName); - propReadOnly.addEventListener('change', (e) => { - field.readOnly = (e.target as HTMLInputElement).checked - }) - - const propBorderColor = document.getElementById('propBorderColor') as HTMLInputElement - const propHideBorder = document.getElementById('propHideBorder') as HTMLInputElement - - propBorderColor.addEventListener('input', (e) => { - field.borderColor = (e.target as HTMLInputElement).value - }) - - propHideBorder.addEventListener('change', (e) => { - field.hideBorder = (e.target as HTMLInputElement).checked - }) - - deleteBtn.addEventListener('click', () => { - deleteField(field) - }) - - // Specific listeners - if (field.type === 'text') { - const propValue = document.getElementById('propValue') as HTMLInputElement - const propMaxLength = document.getElementById('propMaxLength') as HTMLInputElement - const propComb = document.getElementById('propComb') as HTMLInputElement - const propFontSize = document.getElementById('propFontSize') as HTMLInputElement - const propTextColor = document.getElementById('propTextColor') as HTMLInputElement - const propAlignment = document.getElementById('propAlignment') as HTMLSelectElement - - propValue.addEventListener('input', (e) => { - field.defaultValue = (e.target as HTMLInputElement).value - const fieldWrapper = document.getElementById(field.id) - if (fieldWrapper) { - const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement - if (textEl) textEl.textContent = field.defaultValue - } - }) - - propMaxLength.addEventListener('input', (e) => { - const val = parseInt((e.target as HTMLInputElement).value) - field.maxLength = isNaN(val) ? 0 : Math.max(0, val) - if (field.maxLength > 0) { - propValue.maxLength = field.maxLength - if (field.defaultValue.length > field.maxLength) { - field.defaultValue = field.defaultValue.substring(0, field.maxLength) - propValue.value = field.defaultValue - const fieldWrapper = document.getElementById(field.id) - if (fieldWrapper) { - const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement - if (textEl) textEl.textContent = field.defaultValue - } - } - } else { - propValue.removeAttribute('maxLength') - } - }) - - propComb.addEventListener('input', (e) => { - const val = parseInt((e.target as HTMLInputElement).value) - field.combCells = isNaN(val) ? 0 : Math.max(0, val) - - if (field.combCells > 0) { - propValue.maxLength = field.combCells - propMaxLength.value = field.combCells.toString() - propMaxLength.disabled = true - field.maxLength = field.combCells - - if (field.defaultValue.length > field.combCells) { - field.defaultValue = field.defaultValue.substring(0, field.combCells) - propValue.value = field.defaultValue - } - } else { - propMaxLength.disabled = false - propValue.removeAttribute('maxLength') - if (field.maxLength > 0) { - propValue.maxLength = field.maxLength - } - } - - // Re-render field visual only, NOT the properties panel - const fieldWrapper = document.getElementById(field.id) - if (fieldWrapper) { - // Update text content - const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement - if (textEl) { - textEl.textContent = field.defaultValue - if (field.combCells > 0) { - textEl.style.backgroundImage = `repeating-linear-gradient(90deg, transparent, transparent calc((100% / ${field.combCells}) - 1px), #e5e7eb calc((100% / ${field.combCells}) - 1px), #e5e7eb calc(100% / ${field.combCells}))` - textEl.style.fontFamily = 'monospace' - textEl.style.letterSpacing = `calc(${field.width / field.combCells}px - 1ch)` - textEl.style.paddingLeft = `calc((${field.width / field.combCells}px - 1ch) / 2)` - textEl.style.overflow = 'hidden' - textEl.style.textAlign = 'left' - textEl.style.justifyContent = 'flex-start' - } else { - textEl.style.backgroundImage = 'none' - textEl.style.fontFamily = 'inherit' - textEl.style.letterSpacing = 'normal' - textEl.style.textAlign = field.alignment - textEl.style.justifyContent = field.alignment === 'left' ? 'flex-start' : field.alignment === 'right' ? 'flex-end' : 'center' - } - } - } - }) - - propFontSize.addEventListener('input', (e) => { - field.fontSize = parseInt((e.target as HTMLInputElement).value) - const fieldWrapper = document.getElementById(field.id) - if (fieldWrapper) { - const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement - if (textEl) textEl.style.fontSize = field.fontSize + 'px' - } - }) - - propTextColor.addEventListener('input', (e) => { - field.textColor = (e.target as HTMLInputElement).value - const fieldWrapper = document.getElementById(field.id) - if (fieldWrapper) { - const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement - if (textEl) textEl.style.color = field.textColor - } - }) - - propAlignment.addEventListener('change', (e) => { - field.alignment = (e.target as HTMLSelectElement).value as 'left' | 'center' | 'right' - const fieldWrapper = document.getElementById(field.id) - if (fieldWrapper) { - const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement - if (textEl) { - textEl.style.textAlign = field.alignment - textEl.style.justifyContent = field.alignment === 'left' ? 'flex-start' : field.alignment === 'right' ? 'flex-end' : 'center' - } - } - }) - - const propMultilineBtn = document.getElementById('propMultilineBtn') as HTMLButtonElement - if (propMultilineBtn) { - propMultilineBtn.addEventListener('click', () => { - field.multiline = !field.multiline - - // Update Toggle Button UI - const span = propMultilineBtn.querySelector('span') - if (field.multiline) { - propMultilineBtn.classList.remove('bg-gray-500') - propMultilineBtn.classList.add('bg-indigo-600') - span?.classList.remove('translate-x-0') - span?.classList.add('translate-x-6') - } else { - propMultilineBtn.classList.remove('bg-indigo-600') - propMultilineBtn.classList.add('bg-gray-500') - span?.classList.remove('translate-x-6') - span?.classList.add('translate-x-0') - } - - // Update Canvas UI - const fieldWrapper = document.getElementById(field.id) - if (fieldWrapper) { - const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement - if (textEl) { - if (field.multiline) { - textEl.style.whiteSpace = 'pre-wrap' - textEl.style.alignItems = 'flex-start' - textEl.style.overflow = 'hidden' - } else { - textEl.style.whiteSpace = 'nowrap' - textEl.style.alignItems = 'center' - textEl.style.overflow = 'hidden' - } - } - } - }) - } - } else if (field.type === 'checkbox' || field.type === 'radio') { - const propCheckedBtn = document.getElementById('propCheckedBtn') as HTMLButtonElement - - propCheckedBtn.addEventListener('click', () => { - field.checked = !field.checked - - // Update Toggle Button UI - const span = propCheckedBtn.querySelector('span') - if (field.checked) { - propCheckedBtn.classList.remove('bg-gray-500') - propCheckedBtn.classList.add('bg-indigo-600') - span?.classList.remove('translate-x-0') - span?.classList.add('translate-x-6') - } else { - propCheckedBtn.classList.remove('bg-indigo-600') - propCheckedBtn.classList.add('bg-gray-500') - span?.classList.remove('translate-x-6') - span?.classList.add('translate-x-0') - } - - // Update Canvas UI - const fieldWrapper = document.getElementById(field.id) - if (fieldWrapper) { - const contentEl = fieldWrapper.querySelector('.field-content') as HTMLElement - if (contentEl) { - if (field.type === 'checkbox') { - contentEl.innerHTML = field.checked ? '' : '' - } else { - contentEl.innerHTML = field.checked ? '
' : '' - } - } - } - }) - - if (field.type === 'radio') { - const propGroupName = document.getElementById('propGroupName') as HTMLInputElement - const propExportValue = document.getElementById('propExportValue') as HTMLInputElement - - propGroupName.addEventListener('input', (e) => { - field.groupName = (e.target as HTMLInputElement).value - }) - propExportValue.addEventListener('input', (e) => { - field.exportValue = (e.target as HTMLInputElement).value - }) - } - } else if (field.type === 'dropdown' || field.type === 'optionlist') { - const propOptions = document.getElementById('propOptions') as HTMLTextAreaElement - propOptions.addEventListener('input', (e) => { - // We split by newline OR comma for the actual options array - const val = (e.target as HTMLTextAreaElement).value - field.options = val.split(/[\n,]/).map(s => s.trim()).filter(s => s.length > 0) - - const propSelectedOption = document.getElementById('propSelectedOption') as HTMLSelectElement - if (propSelectedOption) { - const currentVal = field.defaultValue - propSelectedOption.innerHTML = '' + - field.options?.map(opt => ``).join('') - - if (currentVal && field.options && !field.options.includes(currentVal)) { - field.defaultValue = '' - propSelectedOption.value = '' - } - } - - renderField(field) - }) - - const propSelectedOption = document.getElementById('propSelectedOption') as HTMLSelectElement - propSelectedOption.addEventListener('change', (e) => { - field.defaultValue = (e.target as HTMLSelectElement).value - - // Update visual on canvas - renderField(field) - }) - } else if (field.type === 'button') { - const propLabel = document.getElementById('propLabel') as HTMLInputElement - propLabel.addEventListener('input', (e) => { - field.label = (e.target as HTMLInputElement).value - const fieldWrapper = document.getElementById(field.id) - if (fieldWrapper) { - const contentEl = fieldWrapper.querySelector('.field-content') as HTMLElement - if (contentEl) contentEl.textContent = field.label || 'Button' - } - }) - - const propAction = document.getElementById('propAction') as HTMLSelectElement - const propUrlContainer = document.getElementById('propUrlContainer') as HTMLDivElement - const propJsContainer = document.getElementById('propJsContainer') as HTMLDivElement - const propShowHideContainer = document.getElementById('propShowHideContainer') as HTMLDivElement - - propAction.addEventListener('change', (e) => { - field.action = (e.target as HTMLSelectElement).value as any - - // Show/hide containers - propUrlContainer.classList.add('hidden') - propJsContainer.classList.add('hidden') - propShowHideContainer.classList.add('hidden') - - if (field.action === 'url') { - propUrlContainer.classList.remove('hidden') - } else if (field.action === 'js') { - propJsContainer.classList.remove('hidden') - } else if (field.action === 'showHide') { - propShowHideContainer.classList.remove('hidden') - } - }) - - const propActionUrl = document.getElementById('propActionUrl') as HTMLInputElement - propActionUrl.addEventListener('input', (e) => { - field.actionUrl = (e.target as HTMLInputElement).value - }) - - const propJsScript = document.getElementById('propJsScript') as HTMLTextAreaElement - if (propJsScript) { - propJsScript.addEventListener('input', (e) => { - field.jsScript = (e.target as HTMLTextAreaElement).value - }) - } - - const propTargetField = document.getElementById('propTargetField') as HTMLSelectElement - if (propTargetField) { - propTargetField.addEventListener('change', (e) => { - field.targetFieldName = (e.target as HTMLSelectElement).value - }) - } - - const propVisibilityAction = document.getElementById('propVisibilityAction') as HTMLSelectElement - if (propVisibilityAction) { - propVisibilityAction.addEventListener('change', (e) => { - field.visibilityAction = (e.target as HTMLSelectElement).value as any - }) - } - } else if (field.type === 'signature') { - // No specific listeners for signature fields yet - } else if (field.type === 'date') { - const propDateFormat = document.getElementById('propDateFormat') as HTMLSelectElement - if (propDateFormat) { - propDateFormat.addEventListener('change', (e) => { - field.dateFormat = (e.target as HTMLSelectElement).value - // Update canvas preview - const fieldWrapper = document.getElementById(field.id) - if (fieldWrapper) { - const textSpan = fieldWrapper.querySelector('.date-format-text') as HTMLElement - if (textSpan) { - textSpan.textContent = field.dateFormat - } - } - // Re-initialize lucide icons in the properties panel - setTimeout(() => (window as any).lucide?.createIcons(), 0) - }) - } - } else if (field.type === 'image') { - const propLabel = document.getElementById('propLabel') as HTMLInputElement - propLabel.addEventListener('input', (e) => { - field.label = (e.target as HTMLInputElement).value - renderField(field) - }) + if (isDuplicateInFields || isDuplicateInPdf) { + nameError.textContent = `Field name "${newName}" already exists in this ${isDuplicateInPdf ? 'PDF' : 'form'}. Please try using a unique name.`; + nameError.classList.remove('hidden'); + propName.classList.add('border-red-500'); + return false; } + + nameError.classList.add('hidden'); + propName.classList.remove('border-red-500'); + return true; + }; + + propName.addEventListener('input', (e) => { + const newName = (e.target as HTMLInputElement).value.trim(); + validateName(newName); + }); + + propName.addEventListener('change', (e) => { + const newName = (e.target as HTMLInputElement).value.trim(); + + if (!validateName(newName)) { + (e.target as HTMLInputElement).value = field.name; + return; + } + + field.name = newName; + const fieldWrapper = document.getElementById(field.id); + if (fieldWrapper) { + const label = fieldWrapper.querySelector('.field-label') as HTMLElement; + if (label) label.textContent = field.name; + } + }); + + propTooltip.addEventListener('input', (e) => { + field.tooltip = (e.target as HTMLInputElement).value; + }); + + if (field.type === 'radio') { + const existingGroupsSelect = document.getElementById( + 'existingGroups' + ) as HTMLSelectElement; + if (existingGroupsSelect) { + existingGroupsSelect.addEventListener('change', (e) => { + const selectedGroup = (e.target as HTMLSelectElement).value; + if (selectedGroup) { + propName.value = selectedGroup; + field.name = selectedGroup; + validateName(selectedGroup); + + // Update field label + const fieldWrapper = document.getElementById(field.id); + if (fieldWrapper) { + const label = fieldWrapper.querySelector( + '.field-label' + ) as HTMLElement; + if (label) label.textContent = field.name; + } + } + }); + } + } + + propRequired.addEventListener('change', (e) => { + field.required = (e.target as HTMLInputElement).checked; + }); + + propReadOnly.addEventListener('change', (e) => { + field.readOnly = (e.target as HTMLInputElement).checked; + }); + + const propBorderColor = document.getElementById( + 'propBorderColor' + ) as HTMLInputElement; + const propHideBorder = document.getElementById( + 'propHideBorder' + ) as HTMLInputElement; + + propBorderColor.addEventListener('input', (e) => { + field.borderColor = (e.target as HTMLInputElement).value; + }); + + propHideBorder.addEventListener('change', (e) => { + field.hideBorder = (e.target as HTMLInputElement).checked; + }); + + deleteBtn.addEventListener('click', () => { + deleteField(field); + }); + + // Specific listeners + if (field.type === 'text') { + const propValue = document.getElementById('propValue') as HTMLInputElement; + const propMaxLength = document.getElementById( + 'propMaxLength' + ) as HTMLInputElement; + const propComb = document.getElementById('propComb') as HTMLInputElement; + const propFontSize = document.getElementById( + 'propFontSize' + ) as HTMLInputElement; + const propTextColor = document.getElementById( + 'propTextColor' + ) as HTMLInputElement; + const propAlignment = document.getElementById( + 'propAlignment' + ) as HTMLSelectElement; + + propValue.addEventListener('input', (e) => { + field.defaultValue = (e.target as HTMLInputElement).value; + const fieldWrapper = document.getElementById(field.id); + if (fieldWrapper) { + const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement; + if (textEl) textEl.textContent = field.defaultValue; + } + }); + + propMaxLength.addEventListener('input', (e) => { + const val = parseInt((e.target as HTMLInputElement).value); + field.maxLength = isNaN(val) ? 0 : Math.max(0, val); + if (field.maxLength > 0) { + propValue.maxLength = field.maxLength; + if (field.defaultValue.length > field.maxLength) { + field.defaultValue = field.defaultValue.substring(0, field.maxLength); + propValue.value = field.defaultValue; + const fieldWrapper = document.getElementById(field.id); + if (fieldWrapper) { + const textEl = fieldWrapper.querySelector( + '.field-text' + ) as HTMLElement; + if (textEl) textEl.textContent = field.defaultValue; + } + } + } else { + propValue.removeAttribute('maxLength'); + } + }); + + propComb.addEventListener('input', (e) => { + const val = parseInt((e.target as HTMLInputElement).value); + field.combCells = isNaN(val) ? 0 : Math.max(0, val); + + if (field.combCells > 0) { + propValue.maxLength = field.combCells; + propMaxLength.value = field.combCells.toString(); + propMaxLength.disabled = true; + field.maxLength = field.combCells; + + if (field.defaultValue.length > field.combCells) { + field.defaultValue = field.defaultValue.substring(0, field.combCells); + propValue.value = field.defaultValue; + } + } else { + propMaxLength.disabled = false; + propValue.removeAttribute('maxLength'); + if (field.maxLength > 0) { + propValue.maxLength = field.maxLength; + } + } + + // Re-render field visual only, NOT the properties panel + const fieldWrapper = document.getElementById(field.id); + if (fieldWrapper) { + // Update text content + const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement; + if (textEl) { + textEl.textContent = field.defaultValue; + if (field.combCells > 0) { + textEl.style.backgroundImage = `repeating-linear-gradient(90deg, transparent, transparent calc((100% / ${field.combCells}) - 1px), #e5e7eb calc((100% / ${field.combCells}) - 1px), #e5e7eb calc(100% / ${field.combCells}))`; + textEl.style.fontFamily = 'monospace'; + textEl.style.letterSpacing = `calc(${field.width / field.combCells}px - 1ch)`; + textEl.style.paddingLeft = `calc((${field.width / field.combCells}px - 1ch) / 2)`; + textEl.style.overflow = 'hidden'; + textEl.style.textAlign = 'left'; + textEl.style.justifyContent = 'flex-start'; + } else { + textEl.style.backgroundImage = 'none'; + textEl.style.fontFamily = 'inherit'; + textEl.style.letterSpacing = 'normal'; + textEl.style.textAlign = field.alignment; + textEl.style.justifyContent = + field.alignment === 'left' + ? 'flex-start' + : field.alignment === 'right' + ? 'flex-end' + : 'center'; + } + } + } + }); + + propFontSize.addEventListener('input', (e) => { + field.fontSize = parseInt((e.target as HTMLInputElement).value); + const fieldWrapper = document.getElementById(field.id); + if (fieldWrapper) { + const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement; + if (textEl) textEl.style.fontSize = field.fontSize + 'px'; + } + }); + + propTextColor.addEventListener('input', (e) => { + field.textColor = (e.target as HTMLInputElement).value; + const fieldWrapper = document.getElementById(field.id); + if (fieldWrapper) { + const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement; + if (textEl) textEl.style.color = field.textColor; + } + }); + + propAlignment.addEventListener('change', (e) => { + field.alignment = (e.target as HTMLSelectElement).value as + | 'left' + | 'center' + | 'right'; + const fieldWrapper = document.getElementById(field.id); + if (fieldWrapper) { + const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement; + if (textEl) { + textEl.style.textAlign = field.alignment; + textEl.style.justifyContent = + field.alignment === 'left' + ? 'flex-start' + : field.alignment === 'right' + ? 'flex-end' + : 'center'; + } + } + }); + + const propMultilineBtn = document.getElementById( + 'propMultilineBtn' + ) as HTMLButtonElement; + if (propMultilineBtn) { + propMultilineBtn.addEventListener('click', () => { + field.multiline = !field.multiline; + + // Update Toggle Button UI + const span = propMultilineBtn.querySelector('span'); + if (field.multiline) { + propMultilineBtn.classList.remove('bg-gray-500'); + propMultilineBtn.classList.add('bg-indigo-600'); + span?.classList.remove('translate-x-0'); + span?.classList.add('translate-x-6'); + } else { + propMultilineBtn.classList.remove('bg-indigo-600'); + propMultilineBtn.classList.add('bg-gray-500'); + span?.classList.remove('translate-x-6'); + span?.classList.add('translate-x-0'); + } + + // Update Canvas UI + const fieldWrapper = document.getElementById(field.id); + if (fieldWrapper) { + const textEl = fieldWrapper.querySelector( + '.field-text' + ) as HTMLElement; + if (textEl) { + if (field.multiline) { + textEl.style.whiteSpace = 'pre-wrap'; + textEl.style.alignItems = 'flex-start'; + textEl.style.overflow = 'hidden'; + } else { + textEl.style.whiteSpace = 'nowrap'; + textEl.style.alignItems = 'center'; + textEl.style.overflow = 'hidden'; + } + } + } + }); + } + } else if (field.type === 'checkbox' || field.type === 'radio') { + const propCheckedBtn = document.getElementById( + 'propCheckedBtn' + ) as HTMLButtonElement; + + propCheckedBtn.addEventListener('click', () => { + field.checked = !field.checked; + + // Update Toggle Button UI + const span = propCheckedBtn.querySelector('span'); + if (field.checked) { + propCheckedBtn.classList.remove('bg-gray-500'); + propCheckedBtn.classList.add('bg-indigo-600'); + span?.classList.remove('translate-x-0'); + span?.classList.add('translate-x-6'); + } else { + propCheckedBtn.classList.remove('bg-indigo-600'); + propCheckedBtn.classList.add('bg-gray-500'); + span?.classList.remove('translate-x-6'); + span?.classList.add('translate-x-0'); + } + + // Update Canvas UI + const fieldWrapper = document.getElementById(field.id); + if (fieldWrapper) { + const contentEl = fieldWrapper.querySelector( + '.field-content' + ) as HTMLElement; + if (contentEl) { + if (field.type === 'checkbox') { + contentEl.innerHTML = field.checked + ? '' + : ''; + } else { + contentEl.innerHTML = field.checked + ? '
' + : ''; + } + } + } + }); + + if (field.type === 'radio') { + const propGroupName = document.getElementById( + 'propGroupName' + ) as HTMLInputElement; + const propExportValue = document.getElementById( + 'propExportValue' + ) as HTMLInputElement; + + propGroupName.addEventListener('input', (e) => { + field.groupName = (e.target as HTMLInputElement).value; + }); + propExportValue.addEventListener('input', (e) => { + field.exportValue = (e.target as HTMLInputElement).value; + }); + } + } else if (field.type === 'dropdown' || field.type === 'optionlist') { + const propOptions = document.getElementById( + 'propOptions' + ) as HTMLTextAreaElement; + propOptions.addEventListener('input', (e) => { + // We split by newline OR comma for the actual options array + const val = (e.target as HTMLTextAreaElement).value; + field.options = val + .split(/[\n,]/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + const propSelectedOption = document.getElementById( + 'propSelectedOption' + ) as HTMLSelectElement; + if (propSelectedOption) { + const currentVal = field.defaultValue; + propSelectedOption.innerHTML = + '' + + field.options + ?.map( + (opt) => + `` + ) + .join(''); + + if ( + currentVal && + field.options && + !field.options.includes(currentVal) + ) { + field.defaultValue = ''; + propSelectedOption.value = ''; + } + } + + renderField(field); + }); + + const propSelectedOption = document.getElementById( + 'propSelectedOption' + ) as HTMLSelectElement; + propSelectedOption.addEventListener('change', (e) => { + field.defaultValue = (e.target as HTMLSelectElement).value; + + // Update visual on canvas + renderField(field); + }); + } else if (field.type === 'button') { + const propLabel = document.getElementById('propLabel') as HTMLInputElement; + propLabel.addEventListener('input', (e) => { + field.label = (e.target as HTMLInputElement).value; + const fieldWrapper = document.getElementById(field.id); + if (fieldWrapper) { + const contentEl = fieldWrapper.querySelector( + '.field-content' + ) as HTMLElement; + if (contentEl) contentEl.textContent = field.label || 'Button'; + } + }); + + const propAction = document.getElementById( + 'propAction' + ) as HTMLSelectElement; + const propUrlContainer = document.getElementById( + 'propUrlContainer' + ) as HTMLDivElement; + const propJsContainer = document.getElementById( + 'propJsContainer' + ) as HTMLDivElement; + const propShowHideContainer = document.getElementById( + 'propShowHideContainer' + ) as HTMLDivElement; + + propAction.addEventListener('change', (e) => { + field.action = (e.target as HTMLSelectElement).value as any; + + // Show/hide containers + propUrlContainer.classList.add('hidden'); + propJsContainer.classList.add('hidden'); + propShowHideContainer.classList.add('hidden'); + + if (field.action === 'url') { + propUrlContainer.classList.remove('hidden'); + } else if (field.action === 'js') { + propJsContainer.classList.remove('hidden'); + } else if (field.action === 'showHide') { + propShowHideContainer.classList.remove('hidden'); + } + }); + + const propActionUrl = document.getElementById( + 'propActionUrl' + ) as HTMLInputElement; + propActionUrl.addEventListener('input', (e) => { + field.actionUrl = (e.target as HTMLInputElement).value; + }); + + const propJsScript = document.getElementById( + 'propJsScript' + ) as HTMLTextAreaElement; + if (propJsScript) { + propJsScript.addEventListener('input', (e) => { + field.jsScript = (e.target as HTMLTextAreaElement).value; + }); + } + + const propTargetField = document.getElementById( + 'propTargetField' + ) as HTMLSelectElement; + if (propTargetField) { + propTargetField.addEventListener('change', (e) => { + field.targetFieldName = (e.target as HTMLSelectElement).value; + }); + } + + const propVisibilityAction = document.getElementById( + 'propVisibilityAction' + ) as HTMLSelectElement; + if (propVisibilityAction) { + propVisibilityAction.addEventListener('change', (e) => { + field.visibilityAction = (e.target as HTMLSelectElement).value as any; + }); + } + } else if (field.type === 'signature') { + // No specific listeners for signature fields yet + } else if (field.type === 'date') { + const propDateFormat = document.getElementById( + 'propDateFormat' + ) as HTMLSelectElement; + const customFormatContainer = document.getElementById( + 'customFormatContainer' + ) as HTMLDivElement; + const propCustomFormat = document.getElementById( + 'propCustomFormat' + ) as HTMLInputElement; + const dateFormatExample = document.getElementById( + 'dateFormatExample' + ) as HTMLSpanElement; + + const formatDateExample = (format: string): string => { + const now = new Date(); + const d = now.getDate(); + const dd = d.toString().padStart(2, '0'); + const m = now.getMonth() + 1; + const mm = m.toString().padStart(2, '0'); + const yy = now.getFullYear().toString().slice(-2); + const yyyy = now.getFullYear().toString(); + const h = now.getHours() % 12 || 12; + const HH = now.getHours().toString().padStart(2, '0'); + const MM = now.getMinutes().toString().padStart(2, '0'); + const tt = now.getHours() >= 12 ? 'PM' : 'AM'; + const monthNames = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + const monthNamesFull = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + const mmm = monthNames[now.getMonth()]; + const mmmm = monthNamesFull[now.getMonth()]; + + return format + .replace(/mmmm/g, mmmm) + .replace(/mmm/g, mmm) + .replace(/mm/g, mm) + .replace(/m/g, m.toString()) + .replace(/dddd/g, dd) + .replace(/dd/g, dd) + .replace(/d/g, d.toString()) + .replace(/yyyy/g, yyyy) + .replace(/yy/g, yy) + .replace(/HH/g, HH) + .replace(/h/g, h.toString()) + .replace(/MM/g, MM) + .replace(/tt/g, tt); + }; + + const updateExample = () => { + if (dateFormatExample) { + dateFormatExample.textContent = formatDateExample( + field.dateFormat || 'mm/dd/yyyy' + ); + } + }; + + updateExample(); + + if (propDateFormat) { + propDateFormat.addEventListener('change', (e) => { + const value = (e.target as HTMLSelectElement).value; + if (value === 'custom') { + customFormatContainer?.classList.remove('hidden'); + if (propCustomFormat && propCustomFormat.value) { + field.dateFormat = propCustomFormat.value; + } + } else { + customFormatContainer?.classList.add('hidden'); + field.dateFormat = value; + } + updateExample(); + const fieldWrapper = document.getElementById(field.id); + if (fieldWrapper) { + const textSpan = fieldWrapper.querySelector( + '.date-format-text' + ) as HTMLElement; + if (textSpan) { + textSpan.textContent = field.dateFormat; + } + } + setTimeout(() => (window as any).lucide?.createIcons(), 0); + }); + } + + if (propCustomFormat) { + propCustomFormat.addEventListener('input', (e) => { + field.dateFormat = (e.target as HTMLInputElement).value || 'mm/dd/yyyy'; + updateExample(); + const fieldWrapper = document.getElementById(field.id); + if (fieldWrapper) { + const textSpan = fieldWrapper.querySelector( + '.date-format-text' + ) as HTMLElement; + if (textSpan) { + textSpan.textContent = field.dateFormat; + } + } + }); + } + } else if (field.type === 'image') { + const propLabel = document.getElementById('propLabel') as HTMLInputElement; + propLabel.addEventListener('input', (e) => { + field.label = (e.target as HTMLInputElement).value; + renderField(field); + }); + } } // Hide properties panel function hideProperties(): void { - propertiesPanel.innerHTML = '

Select a field to edit properties

' + propertiesPanel.innerHTML = + '

Select a field to edit properties

'; } // Delete field function deleteField(field: FormField): void { - const fieldEl = document.getElementById(field.id) - if (fieldEl) { - fieldEl.remove() - } - fields = fields.filter((f) => f.id !== field.id) - deselectAll() - updateFieldCount() + const fieldEl = document.getElementById(field.id); + if (fieldEl) { + fieldEl.remove(); + } + fields = fields.filter((f) => f.id !== field.id); + deselectAll(); + updateFieldCount(); } // Delete key handler document.addEventListener('keydown', (e) => { - if (e.key === 'Delete' && selectedField) { - deleteField(selectedField) - } else if (e.key === 'Escape' && selectedToolType) { - // Cancel tool selection - toolItems.forEach(item => item.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600')) - selectedToolType = null - canvas.style.cursor = 'default' - } -}) + if (e.key === 'Delete' && selectedField) { + deleteField(selectedField); + } else if (e.key === 'Escape' && selectedToolType) { + // Cancel tool selection + toolItems.forEach((item) => + item.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600') + ); + selectedToolType = null; + canvas.style.cursor = 'default'; + } +}); // Update field count function updateFieldCount(): void { - fieldCountDisplay.textContent = fields.length.toString() + fieldCountDisplay.textContent = fields.length.toString(); } // Download PDF downloadBtn.addEventListener('click', async () => { - // Check for duplicate field names before generating PDF - const nameCount = new Map() - const duplicates: string[] = [] - const conflictsWithPdf: string[] = [] + // Check for duplicate field names before generating PDF + const nameCount = new Map(); + const duplicates: string[] = []; + const conflictsWithPdf: string[] = []; - fields.forEach(field => { - const count = nameCount.get(field.name) || 0 - nameCount.set(field.name, count + 1) + fields.forEach((field) => { + const count = nameCount.get(field.name) || 0; + nameCount.set(field.name, count + 1); - if (existingFieldNames.has(field.name)) { - if (field.type === 'radio' && existingRadioGroups.has(field.name)) { - } else { - conflictsWithPdf.push(field.name) - } - } - }) + if (existingFieldNames.has(field.name)) { + if (field.type === 'radio' && existingRadioGroups.has(field.name)) { + } else { + conflictsWithPdf.push(field.name); + } + } + }); - nameCount.forEach((count, name) => { - if (count > 1) { - const fieldsWithName = fields.filter(f => f.name === name) - const allRadio = fieldsWithName.every(f => f.type === 'radio') + nameCount.forEach((count, name) => { + if (count > 1) { + const fieldsWithName = fields.filter((f) => f.name === name); + const allRadio = fieldsWithName.every((f) => f.type === 'radio'); - if (!allRadio) { - duplicates.push(name) - } - } - }) + if (!allRadio) { + duplicates.push(name); + } + } + }); - if (conflictsWithPdf.length > 0) { - const conflictList = [...new Set(conflictsWithPdf)].map(name => `"${name}"`).join(', ') - showModal( - 'Field Name Conflict', - `The following field names already exist in the uploaded PDF: ${conflictList}. Please rename these fields before downloading.`, - 'error' - ) - return + if (conflictsWithPdf.length > 0) { + const conflictList = [...new Set(conflictsWithPdf)] + .map((name) => `"${name}"`) + .join(', '); + showModal( + 'Field Name Conflict', + `The following field names already exist in the uploaded PDF: ${conflictList}. Please rename these fields before downloading.`, + 'error' + ); + return; + } + + if (duplicates.length > 0) { + const duplicateList = duplicates.map((name) => `"${name}"`).join(', '); + showModal( + 'Duplicate Field Names', + `The following field names are used more than once: ${duplicateList}. Please rename these fields to use unique names before downloading.`, + 'error' + ); + return; + } + + if (fields.length === 0) { + alert('Please add at least one field before downloading.'); + return; + } + + if (pages.length === 0) { + alert('No pages found. Please create a blank PDF or upload one.'); + return; + } + + try { + let pdfDoc: PDFDocument; + + if (uploadedPdfDoc) { + pdfDoc = uploadedPdfDoc; + } else { + pdfDoc = await PDFDocument.create(); + + for (const pageData of pages) { + pdfDoc.addPage([pageData.width, pageData.height]); + } } - if (duplicates.length > 0) { - const duplicateList = duplicates.map(name => `"${name}"`).join(', ') - showModal( - 'Duplicate Field Names', - `The following field names are used more than once: ${duplicateList}. Please rename these fields to use unique names before downloading.`, - 'error' - ) - return - } + const form = pdfDoc.getForm(); - if (fields.length === 0) { - alert('Please add at least one field before downloading.') - return - } + const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica); - if (pages.length === 0) { - alert('No pages found. Please create a blank PDF or upload one.') - return - } + // Set document metadata for accessibility + pdfDoc.setTitle('Fillable Form'); + pdfDoc.setAuthor('BentoPDF'); + pdfDoc.setLanguage('en-US'); - try { - let pdfDoc: PDFDocument + const radioGroups = new Map(); // Track created radio groups - if (uploadedPdfDoc) { - pdfDoc = uploadedPdfDoc + for (const field of fields) { + const pageData = pages[field.pageIndex]; + if (!pageData) continue; + + const pdfPage = pdfDoc.getPage(field.pageIndex); + const { height: pageHeight } = pdfPage.getSize(); + + const scaleX = 1 / pdfViewerScale; + const scaleY = 1 / pdfViewerScale; + + const adjustedX = field.x - pdfViewerOffset.x; + const adjustedY = field.y - pdfViewerOffset.y; + + const x = adjustedX * scaleX; + const y = pageHeight - adjustedY * scaleY - field.height * scaleY; + const width = field.width * scaleX; + const height = field.height * scaleY; + + console.log(`Field "${field.name}":`, { + screenPos: { x: field.x, y: field.y }, + adjustedPos: { x: adjustedX, y: adjustedY }, + pdfPos: { x, y, width, height }, + metrics: { offset: pdfViewerOffset, scale: pdfViewerScale }, + }); + + if (field.type === 'text') { + const textField = form.createTextField(field.name); + const rgbColor = hexToRgb(field.textColor); + const borderRgb = hexToRgb(field.borderColor || '#000000'); + + textField.addToPage(pdfPage, { + x: x, + y: y, + width: width, + height: height, + borderWidth: field.hideBorder ? 0 : 1, + borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b), + backgroundColor: rgb(1, 1, 1), + textColor: rgb(rgbColor.r, rgbColor.g, rgbColor.b), + }); + + textField.setText(field.defaultValue); + textField.setFontSize(field.fontSize); + + // Set alignment + if (field.alignment === 'center') { + textField.setAlignment(TextAlignment.Center); + } else if (field.alignment === 'right') { + textField.setAlignment(TextAlignment.Right); } else { - pdfDoc = await PDFDocument.create() - - for (const pageData of pages) { - pdfDoc.addPage([pageData.width, pageData.height]) - } + textField.setAlignment(TextAlignment.Left); } - const form = pdfDoc.getForm() - - const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica) - - // Set document metadata for accessibility - pdfDoc.setTitle('Fillable Form') - pdfDoc.setAuthor('BentoPDF') - pdfDoc.setLanguage('en-US') - - const radioGroups = new Map() // Track created radio groups - - for (const field of fields) { - const pageData = pages[field.pageIndex] - if (!pageData) continue - - const pdfPage = pdfDoc.getPage(field.pageIndex) - const { height: pageHeight } = pdfPage.getSize() - - const scaleX = 1 / pdfViewerScale - const scaleY = 1 / pdfViewerScale - - const adjustedX = field.x - pdfViewerOffset.x - const adjustedY = field.y - pdfViewerOffset.y - - const x = adjustedX * scaleX - const y = pageHeight - (adjustedY * scaleY) - (field.height * scaleY) - const width = field.width * scaleX - const height = field.height * scaleY - - console.log(`Field "${field.name}":`, { - screenPos: { x: field.x, y: field.y }, - adjustedPos: { x: adjustedX, y: adjustedY }, - pdfPos: { x, y, width, height }, - metrics: { offset: pdfViewerOffset, scale: pdfViewerScale } - }) - - if (field.type === 'text') { - const textField = form.createTextField(field.name) - const rgbColor = hexToRgb(field.textColor) - const borderRgb = hexToRgb(field.borderColor || '#000000') - - textField.addToPage(pdfPage, { - x: x, - y: y, - width: width, - height: height, - borderWidth: field.hideBorder ? 0 : 1, - borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b), - backgroundColor: rgb(1, 1, 1), - textColor: rgb(rgbColor.r, rgbColor.g, rgbColor.b), - }) - - textField.setText(field.defaultValue) - textField.setFontSize(field.fontSize) - - // Set alignment - if (field.alignment === 'center') { - textField.setAlignment(TextAlignment.Center) - } else if (field.alignment === 'right') { - textField.setAlignment(TextAlignment.Right) - } else { - textField.setAlignment(TextAlignment.Left) - } - - // Handle combing - if (field.combCells > 0) { - textField.setMaxLength(field.combCells) - textField.enableCombing() - } else if (field.maxLength > 0) { - textField.setMaxLength(field.maxLength) - } - - // Disable multiline to prevent RTL issues (unless explicitly enabled) - if (!field.multiline) { - textField.disableMultiline() - } else { - textField.enableMultiline() - } - - // Common properties - if (field.required) textField.enableRequired() - if (field.readOnly) textField.enableReadOnly() - if (field.tooltip) { - textField.acroField.getWidgets().forEach(widget => { - widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)) - }) - } - - } else if (field.type === 'checkbox') { - const checkBox = form.createCheckBox(field.name) - const borderRgb = hexToRgb(field.borderColor || '#000000') - checkBox.addToPage(pdfPage, { - x: x, - y: y, - width: width, - height: height, - borderWidth: field.hideBorder ? 0 : 1, - borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b), - backgroundColor: rgb(1, 1, 1), - }) - if (field.checked) checkBox.check() - if (field.required) checkBox.enableRequired() - if (field.readOnly) checkBox.enableReadOnly() - if (field.tooltip) { - checkBox.acroField.getWidgets().forEach(widget => { - widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)) - }) - } - - } else if (field.type === 'radio') { - const groupName = field.name - let radioGroup - - if (radioGroups.has(groupName)) { - radioGroup = radioGroups.get(groupName) - } else { - const existingField = form.getFieldMaybe(groupName) - - if (existingField) { - radioGroup = existingField - radioGroups.set(groupName, radioGroup) - console.log(`Using existing radio group from PDF: ${groupName}`) - } else { - radioGroup = form.createRadioGroup(groupName) - radioGroups.set(groupName, radioGroup) - console.log(`Created new radio group: ${groupName}`) - } - } - - const borderRgb = hexToRgb(field.borderColor || '#000000') - radioGroup.addOptionToPage(field.exportValue || 'Yes', pdfPage as any, { - x: x, - y: y, - width: width, - height: height, - borderWidth: field.hideBorder ? 0 : 1, - borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b), - backgroundColor: rgb(1, 1, 1), - }) - if (field.checked) radioGroup.select(field.exportValue || 'Yes') - if (field.required) radioGroup.enableRequired() - if (field.readOnly) radioGroup.enableReadOnly() - if (field.tooltip) { - radioGroup.acroField.getWidgets().forEach((widget: any) => { - widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)) - }) - } - - } else if (field.type === 'dropdown') { - const dropdown = form.createDropdown(field.name) - const borderRgb = hexToRgb(field.borderColor || '#000000') - dropdown.addToPage(pdfPage, { - x: x, - y: y, - width: width, - height: height, - borderWidth: field.hideBorder ? 0 : 1, - borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b), - backgroundColor: rgb(1, 1, 1), // Light blue not supported in standard PDF appearance easily without streams - }) - if (field.options) dropdown.setOptions(field.options) - if (field.defaultValue && field.options?.includes(field.defaultValue)) dropdown.select(field.defaultValue) - else if (field.options && field.options.length > 0) dropdown.select(field.options[0]) - - const rgbColor = hexToRgb(field.textColor) - dropdown.acroField.setFontSize(field.fontSize) - dropdown.acroField.setDefaultAppearance( - `0 0 0 rg /Helv ${field.fontSize} Tf` - ) - - if (field.required) dropdown.enableRequired() - if (field.readOnly) dropdown.enableReadOnly() - if (field.tooltip) { - dropdown.acroField.getWidgets().forEach(widget => { - widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)) - }) - } - - } else if (field.type === 'optionlist') { - const optionList = form.createOptionList(field.name) - const borderRgb = hexToRgb(field.borderColor || '#000000') - optionList.addToPage(pdfPage, { - x: x, - y: y, - width: width, - height: height, - borderWidth: field.hideBorder ? 0 : 1, - borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b), - backgroundColor: rgb(1, 1, 1), - }) - if (field.options) optionList.setOptions(field.options) - if (field.defaultValue && field.options?.includes(field.defaultValue)) optionList.select(field.defaultValue) - else if (field.options && field.options.length > 0) optionList.select(field.options[0]) - - const rgbColor = hexToRgb(field.textColor) - optionList.acroField.setFontSize(field.fontSize) - optionList.acroField.setDefaultAppearance( - `0 0 0 rg /Helv ${field.fontSize} Tf` - ) - - if (field.required) optionList.enableRequired() - if (field.readOnly) optionList.enableReadOnly() - if (field.tooltip) { - optionList.acroField.getWidgets().forEach(widget => { - widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)) - }) - } - - } else if (field.type === 'button') { - const button = form.createButton(field.name) - const borderRgb = hexToRgb(field.borderColor || '#000000') - button.addToPage(field.label || 'Button', pdfPage, { - x: x, - y: y, - width: width, - height: height, - borderWidth: field.hideBorder ? 0 : 1, - borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b), - backgroundColor: rgb(0.8, 0.8, 0.8), // Light gray - }) - - // Add Action - if (field.action && field.action !== 'none') { - const widgets = button.acroField.getWidgets() - - widgets.forEach(widget => { - let actionDict: any - - if (field.action === 'reset') { - actionDict = pdfDoc.context.obj({ - Type: 'Action', - S: 'ResetForm' - }) - } else if (field.action === 'print') { - // Print action using JavaScript - actionDict = pdfDoc.context.obj({ - Type: 'Action', - S: 'JavaScript', - JS: 'print();' - }) - } else if (field.action === 'url' && field.actionUrl) { - // Validate URL - let url = field.actionUrl.trim() - if (!url.startsWith('http://') && !url.startsWith('https://')) { - url = 'https://' + url - } - - // Encode URL to handle special characters (RFC3986) - try { - url = encodeURI(url) - } catch (e) { - console.warn('Failed to encode URL:', e) - } - - actionDict = pdfDoc.context.obj({ - Type: 'Action', - S: 'URI', - URI: PDFString.of(url) - }) - } else if (field.action === 'js' && field.jsScript) { - actionDict = pdfDoc.context.obj({ - Type: 'Action', - S: 'JavaScript', - JS: field.jsScript - }) - } else if (field.action === 'showHide' && field.targetFieldName) { - const target = field.targetFieldName - let script = '' - - if (field.visibilityAction === 'show') { - script = `var f = this.getField("${target}"); if(f) f.display = display.visible;` - } else if (field.visibilityAction === 'hide') { - script = `var f = this.getField("${target}"); if(f) f.display = display.hidden;` - } else { - // Toggle - script = `var f = this.getField("${target}"); if(f) f.display = (f.display === display.visible) ? display.hidden : display.visible;` - } - - actionDict = pdfDoc.context.obj({ - Type: 'Action', - S: 'JavaScript', - JS: script - }) - } - - if (actionDict) { - widget.dict.set(PDFName.of('A'), actionDict) - } - }) - } - - if (field.tooltip) { - button.acroField.getWidgets().forEach(widget => { - widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)) - }) - } - } else if (field.type === 'date') { - const dateField = form.createTextField(field.name) - dateField.addToPage(pdfPage, { - x: x, - y: y, - width: width, - height: height, - borderWidth: 1, - borderColor: rgb(0, 0, 0), - backgroundColor: rgb(1, 1, 1), - }) - - // Add Date Format and Keystroke Actions to the FIELD (not widget) - const dateFormat = field.dateFormat || 'mm/dd/yyyy' - - const formatAction = pdfDoc.context.obj({ - Type: 'Action', - S: 'JavaScript', - JS: PDFString.of(`AFDate_FormatEx("${dateFormat}");`) - }) - - const keystrokeAction = pdfDoc.context.obj({ - Type: 'Action', - S: 'JavaScript', - JS: PDFString.of(`AFDate_KeystrokeEx("${dateFormat}");`) - }) - - // Attach AA (Additional Actions) to the field dictionary - const additionalActions = pdfDoc.context.obj({ - F: formatAction, - K: keystrokeAction - }) - dateField.acroField.dict.set(PDFName.of('AA'), additionalActions) - - if (field.required) dateField.enableRequired() - if (field.readOnly) dateField.enableReadOnly() - if (field.tooltip) { - dateField.acroField.getWidgets().forEach(widget => { - widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)) - }) - } - } else if (field.type === 'image') { - const imageBtn = form.createButton(field.name) - imageBtn.addToPage(field.label || 'Click to Upload Image', pdfPage, { - x: x, - y: y, - width: width, - height: height, - borderWidth: 1, - borderColor: rgb(0, 0, 0), - backgroundColor: rgb(0.9, 0.9, 0.9), - }) - - // Add Import Icon Action - const widgets = imageBtn.acroField.getWidgets() - widgets.forEach(widget => { - const actionDict = pdfDoc.context.obj({ - Type: 'Action', - S: 'JavaScript', - JS: 'event.target.buttonImportIcon();' - }) - widget.dict.set(PDFName.of('A'), actionDict) - - // Set Appearance Characteristics (MK) -> Text Position (TP) = 1 (Icon Only) - // This ensures the image replaces the text when uploaded - // IF (Icon Fit) -> SW: A (Always Scale), S: A (Anamorphic/Fill) - const mkDict = pdfDoc.context.obj({ - TP: 1, - BG: [0.9, 0.9, 0.9], // Background color (Light Gray) - BC: [0, 0, 0], // Border color (Black) - IF: { - SW: PDFName.of('A'), - S: PDFName.of('A'), - FB: true - } - }) - widget.dict.set(PDFName.of('MK'), mkDict) - }) - - if (field.tooltip) { - imageBtn.acroField.getWidgets().forEach(widget => { - widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)) - }) - } - } else if (field.type === 'signature') { - const context = pdfDoc.context - - // Create the signature field dictionary with FT = Sig - const sigDict = context.obj({ - FT: PDFName.of('Sig'), - T: PDFString.of(field.name), - Kids: [], - }) as PDFDict - const sigRef = context.register(sigDict) - - // Create the widget annotation for the signature field - const widgetDict = context.obj({ - Type: PDFName.of('Annot'), - Subtype: PDFName.of('Widget'), - Rect: [x, y, x + width, y + height], - F: 4, // Print flag - P: pdfPage.ref, - Parent: sigRef, - }) as PDFDict - - // Add border and background appearance - const borderStyle = context.obj({ - W: 1, // Border width - S: PDFName.of('S'), // Solid border - }) as PDFDict - widgetDict.set(PDFName.of('BS'), borderStyle) - widgetDict.set(PDFName.of('BC'), context.obj([0, 0, 0])) // Border color (black) - widgetDict.set(PDFName.of('BG'), context.obj([0.95, 0.95, 0.95])) // Background color - - const widgetRef = context.register(widgetDict) - - const kidsArray = sigDict.get(PDFName.of('Kids')) as PDFArray - kidsArray.push(widgetRef) - - pdfPage.node.addAnnot(widgetRef) - - const acroForm = form.acroForm - acroForm.addField(sigRef) - - // Add tooltip if specified - if (field.tooltip) { - widgetDict.set(PDFName.of('TU'), PDFString.of(field.tooltip)) - } - } + // Handle combing + if (field.combCells > 0) { + textField.setMaxLength(field.combCells); + textField.enableCombing(); + } else if (field.maxLength > 0) { + textField.setMaxLength(field.maxLength); } - form.updateFieldAppearances(helveticaFont) - - const pdfBytes = await pdfDoc.save() - const blob = new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }) - downloadFile(blob, 'fillable-form.pdf') - showModal('Success', 'Your PDF has been downloaded successfully.', 'info', () => { - resetToInitial() - }, 'Okay') - } catch (error) { - console.error('Error generating PDF:', error) - const errorMessage = (error as Error).message - - // Check if it's a duplicate field name error - if (errorMessage.includes('A field already exists with the specified name')) { - // Extract the field name from the error message - const match = errorMessage.match(/A field already exists with the specified name: "(.+?)"/) - const fieldName = match ? match[1] : 'unknown' - - if (existingRadioGroups.has(fieldName)) { - console.log(`Adding to existing radio group: ${fieldName}`) - } else { - showModal('Duplicate Field Name', `A field named "${fieldName}" already exists. Please rename this field to use a unique name before downloading.`, 'error') - } + // Disable multiline to prevent RTL issues (unless explicitly enabled) + if (!field.multiline) { + textField.disableMultiline(); } else { - showModal('Error', 'Error generating PDF: ' + errorMessage, 'error') + textField.enableMultiline(); } + + // Common properties + if (field.required) textField.enableRequired(); + if (field.readOnly) textField.enableReadOnly(); + if (field.tooltip) { + textField.acroField.getWidgets().forEach((widget) => { + widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)); + }); + } + } else if (field.type === 'checkbox') { + const checkBox = form.createCheckBox(field.name); + const borderRgb = hexToRgb(field.borderColor || '#000000'); + checkBox.addToPage(pdfPage, { + x: x, + y: y, + width: width, + height: height, + borderWidth: field.hideBorder ? 0 : 1, + borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b), + backgroundColor: rgb(1, 1, 1), + }); + if (field.checked) checkBox.check(); + if (field.required) checkBox.enableRequired(); + if (field.readOnly) checkBox.enableReadOnly(); + if (field.tooltip) { + checkBox.acroField.getWidgets().forEach((widget) => { + widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)); + }); + } + } else if (field.type === 'radio') { + const groupName = field.name; + let radioGroup; + + if (radioGroups.has(groupName)) { + radioGroup = radioGroups.get(groupName); + } else { + const existingField = form.getFieldMaybe(groupName); + + if (existingField) { + radioGroup = existingField; + radioGroups.set(groupName, radioGroup); + console.log(`Using existing radio group from PDF: ${groupName}`); + } else { + radioGroup = form.createRadioGroup(groupName); + radioGroups.set(groupName, radioGroup); + console.log(`Created new radio group: ${groupName}`); + } + } + + const borderRgb = hexToRgb(field.borderColor || '#000000'); + radioGroup.addOptionToPage(field.exportValue || 'Yes', pdfPage as any, { + x: x, + y: y, + width: width, + height: height, + borderWidth: field.hideBorder ? 0 : 1, + borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b), + backgroundColor: rgb(1, 1, 1), + }); + if (field.checked) radioGroup.select(field.exportValue || 'Yes'); + if (field.required) radioGroup.enableRequired(); + if (field.readOnly) radioGroup.enableReadOnly(); + if (field.tooltip) { + radioGroup.acroField.getWidgets().forEach((widget: any) => { + widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)); + }); + } + } else if (field.type === 'dropdown') { + const dropdown = form.createDropdown(field.name); + const borderRgb = hexToRgb(field.borderColor || '#000000'); + dropdown.addToPage(pdfPage, { + x: x, + y: y, + width: width, + height: height, + borderWidth: field.hideBorder ? 0 : 1, + borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b), + backgroundColor: rgb(1, 1, 1), // Light blue not supported in standard PDF appearance easily without streams + }); + if (field.options) dropdown.setOptions(field.options); + if (field.defaultValue && field.options?.includes(field.defaultValue)) + dropdown.select(field.defaultValue); + else if (field.options && field.options.length > 0) + dropdown.select(field.options[0]); + + const rgbColor = hexToRgb(field.textColor); + dropdown.acroField.setFontSize(field.fontSize); + dropdown.acroField.setDefaultAppearance( + `0 0 0 rg /Helv ${field.fontSize} Tf` + ); + + if (field.required) dropdown.enableRequired(); + if (field.readOnly) dropdown.enableReadOnly(); + if (field.tooltip) { + dropdown.acroField.getWidgets().forEach((widget) => { + widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)); + }); + } + } else if (field.type === 'optionlist') { + const optionList = form.createOptionList(field.name); + const borderRgb = hexToRgb(field.borderColor || '#000000'); + optionList.addToPage(pdfPage, { + x: x, + y: y, + width: width, + height: height, + borderWidth: field.hideBorder ? 0 : 1, + borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b), + backgroundColor: rgb(1, 1, 1), + }); + if (field.options) optionList.setOptions(field.options); + if (field.defaultValue && field.options?.includes(field.defaultValue)) + optionList.select(field.defaultValue); + else if (field.options && field.options.length > 0) + optionList.select(field.options[0]); + + const rgbColor = hexToRgb(field.textColor); + optionList.acroField.setFontSize(field.fontSize); + optionList.acroField.setDefaultAppearance( + `0 0 0 rg /Helv ${field.fontSize} Tf` + ); + + if (field.required) optionList.enableRequired(); + if (field.readOnly) optionList.enableReadOnly(); + if (field.tooltip) { + optionList.acroField.getWidgets().forEach((widget) => { + widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)); + }); + } + } else if (field.type === 'button') { + const button = form.createButton(field.name); + const borderRgb = hexToRgb(field.borderColor || '#000000'); + button.addToPage(field.label || 'Button', pdfPage, { + x: x, + y: y, + width: width, + height: height, + borderWidth: field.hideBorder ? 0 : 1, + borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b), + backgroundColor: rgb(0.8, 0.8, 0.8), // Light gray + }); + + // Add Action + if (field.action && field.action !== 'none') { + const widgets = button.acroField.getWidgets(); + + widgets.forEach((widget) => { + let actionDict: any; + + if (field.action === 'reset') { + actionDict = pdfDoc.context.obj({ + Type: 'Action', + S: 'ResetForm', + }); + } else if (field.action === 'print') { + // Print action using JavaScript + actionDict = pdfDoc.context.obj({ + Type: 'Action', + S: 'JavaScript', + JS: 'print();', + }); + } else if (field.action === 'url' && field.actionUrl) { + // Validate URL + let url = field.actionUrl.trim(); + if (!url.startsWith('http://') && !url.startsWith('https://')) { + url = 'https://' + url; + } + + // Encode URL to handle special characters (RFC3986) + try { + url = encodeURI(url); + } catch (e) { + console.warn('Failed to encode URL:', e); + } + + actionDict = pdfDoc.context.obj({ + Type: 'Action', + S: 'URI', + URI: PDFString.of(url), + }); + } else if (field.action === 'js' && field.jsScript) { + actionDict = pdfDoc.context.obj({ + Type: 'Action', + S: 'JavaScript', + JS: field.jsScript, + }); + } else if (field.action === 'showHide' && field.targetFieldName) { + const target = field.targetFieldName; + let script = ''; + + if (field.visibilityAction === 'show') { + script = `var f = this.getField("${target}"); if(f) f.display = display.visible;`; + } else if (field.visibilityAction === 'hide') { + script = `var f = this.getField("${target}"); if(f) f.display = display.hidden;`; + } else { + // Toggle + script = `var f = this.getField("${target}"); if(f) f.display = (f.display === display.visible) ? display.hidden : display.visible;`; + } + + actionDict = pdfDoc.context.obj({ + Type: 'Action', + S: 'JavaScript', + JS: script, + }); + } + + if (actionDict) { + widget.dict.set(PDFName.of('A'), actionDict); + } + }); + } + + if (field.tooltip) { + button.acroField.getWidgets().forEach((widget) => { + widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)); + }); + } + } else if (field.type === 'date') { + const dateField = form.createTextField(field.name); + dateField.addToPage(pdfPage, { + x: x, + y: y, + width: width, + height: height, + borderWidth: 1, + borderColor: rgb(0, 0, 0), + backgroundColor: rgb(1, 1, 1), + }); + + // Add Date Format and Keystroke Actions to the FIELD (not widget) + const dateFormat = field.dateFormat || 'mm/dd/yyyy'; + + const formatAction = pdfDoc.context.obj({ + Type: 'Action', + S: 'JavaScript', + JS: PDFString.of(`AFDate_FormatEx("${dateFormat}");`), + }); + + const keystrokeAction = pdfDoc.context.obj({ + Type: 'Action', + S: 'JavaScript', + JS: PDFString.of(`AFDate_KeystrokeEx("${dateFormat}");`), + }); + + // Attach AA (Additional Actions) to the field dictionary + const additionalActions = pdfDoc.context.obj({ + F: formatAction, + K: keystrokeAction, + }); + dateField.acroField.dict.set(PDFName.of('AA'), additionalActions); + + if (field.required) dateField.enableRequired(); + if (field.readOnly) dateField.enableReadOnly(); + if (field.tooltip) { + dateField.acroField.getWidgets().forEach((widget) => { + widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)); + }); + } + } else if (field.type === 'image') { + const imageBtn = form.createButton(field.name); + imageBtn.addToPage(field.label || 'Click to Upload Image', pdfPage, { + x: x, + y: y, + width: width, + height: height, + borderWidth: 1, + borderColor: rgb(0, 0, 0), + backgroundColor: rgb(0.9, 0.9, 0.9), + }); + + // Add Import Icon Action + const widgets = imageBtn.acroField.getWidgets(); + widgets.forEach((widget) => { + const actionDict = pdfDoc.context.obj({ + Type: 'Action', + S: 'JavaScript', + JS: 'event.target.buttonImportIcon();', + }); + widget.dict.set(PDFName.of('A'), actionDict); + + // Set Appearance Characteristics (MK) -> Text Position (TP) = 1 (Icon Only) + // This ensures the image replaces the text when uploaded + // IF (Icon Fit) -> SW: A (Always Scale), S: A (Anamorphic/Fill) + const mkDict = pdfDoc.context.obj({ + TP: 1, + BG: [0.9, 0.9, 0.9], // Background color (Light Gray) + BC: [0, 0, 0], // Border color (Black) + IF: { + SW: PDFName.of('A'), + S: PDFName.of('A'), + FB: true, + }, + }); + widget.dict.set(PDFName.of('MK'), mkDict); + }); + + if (field.tooltip) { + imageBtn.acroField.getWidgets().forEach((widget) => { + widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)); + }); + } + } else if (field.type === 'signature') { + const context = pdfDoc.context; + + // Create the signature field dictionary with FT = Sig + const sigDict = context.obj({ + FT: PDFName.of('Sig'), + T: PDFString.of(field.name), + Kids: [], + }) as PDFDict; + const sigRef = context.register(sigDict); + + // Create the widget annotation for the signature field + const widgetDict = context.obj({ + Type: PDFName.of('Annot'), + Subtype: PDFName.of('Widget'), + Rect: [x, y, x + width, y + height], + F: 4, // Print flag + P: pdfPage.ref, + Parent: sigRef, + }) as PDFDict; + + // Add border and background appearance + const borderStyle = context.obj({ + W: 1, // Border width + S: PDFName.of('S'), // Solid border + }) as PDFDict; + widgetDict.set(PDFName.of('BS'), borderStyle); + widgetDict.set(PDFName.of('BC'), context.obj([0, 0, 0])); // Border color (black) + widgetDict.set(PDFName.of('BG'), context.obj([0.95, 0.95, 0.95])); // Background color + + const widgetRef = context.register(widgetDict); + + const kidsArray = sigDict.get(PDFName.of('Kids')) as PDFArray; + kidsArray.push(widgetRef); + + pdfPage.node.addAnnot(widgetRef); + + const acroForm = form.acroForm; + acroForm.addField(sigRef); + + // Add tooltip if specified + if (field.tooltip) { + widgetDict.set(PDFName.of('TU'), PDFString.of(field.tooltip)); + } + } } -}) + + form.updateFieldAppearances(helveticaFont); + + const pdfBytes = await pdfDoc.save(); + const blob = new Blob([new Uint8Array(pdfBytes)], { + type: 'application/pdf', + }); + downloadFile(blob, 'fillable-form.pdf'); + showModal( + 'Success', + 'Your PDF has been downloaded successfully.', + 'info', + () => { + resetToInitial(); + }, + 'Okay' + ); + } catch (error) { + console.error('Error generating PDF:', error); + const errorMessage = (error as Error).message; + + // Check if it's a duplicate field name error + if ( + errorMessage.includes('A field already exists with the specified name') + ) { + // Extract the field name from the error message + const match = errorMessage.match( + /A field already exists with the specified name: "(.+?)"/ + ); + const fieldName = match ? match[1] : 'unknown'; + + if (existingRadioGroups.has(fieldName)) { + console.log(`Adding to existing radio group: ${fieldName}`); + } else { + showModal( + 'Duplicate Field Name', + `A field named "${fieldName}" already exists. Please rename this field to use a unique name before downloading.`, + 'error' + ); + } + } else { + showModal('Error', 'Error generating PDF: ' + errorMessage, 'error'); + } + } +}); // Back to tools button -const backToToolsBtns = document.querySelectorAll('[id^="back-to-tools"]') as NodeListOf -backToToolsBtns.forEach(btn => { - btn.addEventListener('click', () => { - window.location.href = import.meta.env.BASE_URL - }) -}) +const backToToolsBtns = document.querySelectorAll( + '[id^="back-to-tools"]' +) as NodeListOf; +backToToolsBtns.forEach((btn) => { + btn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); +}); function getPageDimensions(size: string): { width: number; height: number } { - let dimensions: [number, number] - switch (size) { - case 'letter': - dimensions = PageSizes.Letter - break - case 'a4': - dimensions = PageSizes.A4 - break - case 'a5': - dimensions = PageSizes.A5 - break - case 'legal': - dimensions = PageSizes.Legal - break - case 'tabloid': - dimensions = PageSizes.Tabloid - break - case 'a3': - dimensions = PageSizes.A3 - break - case 'custom': - // Get custom dimensions from inputs - const width = parseInt(customWidth.value) || 612 - const height = parseInt(customHeight.value) || 792 - return { width, height } - default: - dimensions = PageSizes.Letter - } - return { width: dimensions[0], height: dimensions[1] } + let dimensions: [number, number]; + switch (size) { + case 'letter': + dimensions = PageSizes.Letter; + break; + case 'a4': + dimensions = PageSizes.A4; + break; + case 'a5': + dimensions = PageSizes.A5; + break; + case 'legal': + dimensions = PageSizes.Legal; + break; + case 'tabloid': + dimensions = PageSizes.Tabloid; + break; + case 'a3': + dimensions = PageSizes.A3; + break; + case 'custom': + // Get custom dimensions from inputs + const width = parseInt(customWidth.value) || 612; + const height = parseInt(customHeight.value) || 792; + return { width, height }; + default: + dimensions = PageSizes.Letter; + } + return { width: dimensions[0], height: dimensions[1] }; } // Reset to initial state function resetToInitial(): void { - fields = [] - pages = [] - currentPageIndex = 0 - uploadedPdfDoc = null - selectedField = null + fields = []; + pages = []; + currentPageIndex = 0; + uploadedPdfDoc = null; + selectedField = null; - canvas.innerHTML = '' + canvas.innerHTML = ''; - propertiesPanel.innerHTML = '

Select a field to edit properties

' + propertiesPanel.innerHTML = + '

Select a field to edit properties

'; - updateFieldCount() + updateFieldCount(); - // Show upload area and hide tool container - uploadArea.classList.remove('hidden') - toolContainer.classList.add('hidden') - pageSizeSelector.classList.add('hidden') - setTimeout(() => createIcons({ icons }), 100) + // Show upload area and hide tool container + uploadArea.classList.remove('hidden'); + toolContainer.classList.add('hidden'); + pageSizeSelector.classList.add('hidden'); + setTimeout(() => createIcons({ icons }), 100); } function createBlankPage(): void { - pages.push({ - index: pages.length, - width: pageSize.width, - height: pageSize.height - }) - updatePageNavigation() + pages.push({ + index: pages.length, + width: pageSize.width, + height: pageSize.height, + }); + updatePageNavigation(); } function switchToPage(pageIndex: number): void { - if (pageIndex < 0 || pageIndex >= pages.length) return + if (pageIndex < 0 || pageIndex >= pages.length) return; - currentPageIndex = pageIndex - renderCanvas() - updatePageNavigation() + currentPageIndex = pageIndex; + renderCanvas(); + updatePageNavigation(); - // Deselect any selected field when switching pages - deselectAll() + // Deselect any selected field when switching pages + deselectAll(); } // Render the canvas for the current page async function renderCanvas(): Promise { - const currentPage = pages[currentPageIndex] - if (!currentPage) return + const currentPage = pages[currentPageIndex]; + if (!currentPage) return; - // Fixed scale for better visibility - const scale = 1.333 + // Fixed scale for better visibility + const scale = 1.333; - currentScale = scale + currentScale = scale; - // Use actual PDF page dimensions (not scaled) - const canvasWidth = currentPage.width * scale - const canvasHeight = currentPage.height * scale + // Use actual PDF page dimensions (not scaled) + const canvasWidth = currentPage.width * scale; + const canvasHeight = currentPage.height * scale; - canvas.style.width = `${canvasWidth}px` - canvas.style.height = `${canvasHeight}px` + canvas.style.width = `${canvasWidth}px`; + canvas.style.height = `${canvasHeight}px`; - canvas.innerHTML = '' + canvas.innerHTML = ''; - if (uploadedPdfDoc) { + if (uploadedPdfDoc) { + try { + const arrayBuffer = await uploadedPdfDoc.save(); + const blob = new Blob([arrayBuffer.buffer as ArrayBuffer], { + type: 'application/pdf', + }); + const blobUrl = URL.createObjectURL(blob); + + const iframe = document.createElement('iframe'); + iframe.src = `${import.meta.env.BASE_URL}pdfjs-viewer/viewer.html?file=${encodeURIComponent(blobUrl)}#page=${currentPageIndex + 1}&toolbar=0`; + iframe.style.width = '100%'; + iframe.style.height = `${canvasHeight}px`; + iframe.style.border = 'none'; + iframe.style.position = 'absolute'; + iframe.style.top = '0'; + iframe.style.left = '0'; + iframe.style.pointerEvents = 'none'; + iframe.style.opacity = '0.8'; + + iframe.onload = () => { try { - const arrayBuffer = await uploadedPdfDoc.save() - const blob = new Blob([arrayBuffer.buffer as ArrayBuffer], { type: 'application/pdf' }) - const blobUrl = URL.createObjectURL(blob) + const viewerWindow = iframe.contentWindow as any; + if (viewerWindow && viewerWindow.PDFViewerApplication) { + const app = viewerWindow.PDFViewerApplication; - const iframe = document.createElement('iframe') - iframe.src = `${import.meta.env.BASE_URL}pdfjs-viewer/viewer.html?file=${encodeURIComponent(blobUrl)}#page=${currentPageIndex + 1}&toolbar=0` - iframe.style.width = '100%' - iframe.style.height = `${canvasHeight}px` - iframe.style.border = 'none' - iframe.style.position = 'absolute' - iframe.style.top = '0' - iframe.style.left = '0' - iframe.style.pointerEvents = 'none' - iframe.style.opacity = '0.8' - - iframe.onload = () => { - try { - const viewerWindow = iframe.contentWindow as any - if (viewerWindow && viewerWindow.PDFViewerApplication) { - const app = viewerWindow.PDFViewerApplication - - const style = viewerWindow.document.createElement('style') - style.textContent = ` + const style = viewerWindow.document.createElement('style'); + style.textContent = ` * { margin: 0 !important; padding: 0 !important; @@ -2128,268 +2555,306 @@ async function renderCanvas(): Promise { border: none !important; box-shadow: none !important; } - ` - viewerWindow.document.head.appendChild(style) + `; + viewerWindow.document.head.appendChild(style); - const checkRender = setInterval(() => { - if (app.pdfViewer && app.pdfViewer.pagesCount > 0) { - clearInterval(checkRender) + const checkRender = setInterval(() => { + if (app.pdfViewer && app.pdfViewer.pagesCount > 0) { + clearInterval(checkRender); - const pageContainer = viewerWindow.document.querySelector('.page') - if (pageContainer) { - const initialRect = pageContainer.getBoundingClientRect() + const pageContainer = + viewerWindow.document.querySelector('.page'); + if (pageContainer) { + const initialRect = pageContainer.getBoundingClientRect(); - const offsetX = -initialRect.left - const offsetY = -initialRect.top - pageContainer.style.transform = `translate(${offsetX}px, ${offsetY}px)` + const offsetX = -initialRect.left; + const offsetY = -initialRect.top; + pageContainer.style.transform = `translate(${offsetX}px, ${offsetY}px)`; - setTimeout(() => { - const rect = pageContainer.getBoundingClientRect() - const style = viewerWindow.getComputedStyle(pageContainer) + setTimeout(() => { + const rect = pageContainer.getBoundingClientRect(); + const style = viewerWindow.getComputedStyle(pageContainer); - const borderLeft = parseFloat(style.borderLeftWidth) || 0 - const borderTop = parseFloat(style.borderTopWidth) || 0 - const borderRight = parseFloat(style.borderRightWidth) || 0 + const borderLeft = parseFloat(style.borderLeftWidth) || 0; + const borderTop = parseFloat(style.borderTopWidth) || 0; + const borderRight = parseFloat(style.borderRightWidth) || 0; - pdfViewerOffset = { - x: rect.left + borderLeft, - y: rect.top + borderTop - } + pdfViewerOffset = { + x: rect.left + borderLeft, + y: rect.top + borderTop, + }; - const contentWidth = rect.width - borderLeft - borderRight - pdfViewerScale = contentWidth / currentPage.width + const contentWidth = rect.width - borderLeft - borderRight; + pdfViewerScale = contentWidth / currentPage.width; - console.log('📏 Calibrated Metrics (force positioned):', { - initialPosition: { left: initialRect.left, top: initialRect.top }, - appliedTransform: { x: offsetX, y: offsetY }, - finalRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height }, - computedBorders: { left: borderLeft, top: borderTop, right: borderRight }, - finalOffset: pdfViewerOffset, - finalScale: pdfViewerScale, - pdfDimensions: { width: currentPage.width, height: currentPage.height } - }) - }, 50) - } - } - }, 100) - } - } catch (e) { - console.error('Error accessing iframe content:', e) + console.log('📏 Calibrated Metrics (force positioned):', { + initialPosition: { + left: initialRect.left, + top: initialRect.top, + }, + appliedTransform: { x: offsetX, y: offsetY }, + finalRect: { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }, + computedBorders: { + left: borderLeft, + top: borderTop, + right: borderRight, + }, + finalOffset: pdfViewerOffset, + finalScale: pdfViewerScale, + pdfDimensions: { + width: currentPage.width, + height: currentPage.height, + }, + }); + }, 50); } - } - - canvas.appendChild(iframe) - - console.log('Canvas dimensions:', { width: canvasWidth, height: canvasHeight, scale: currentScale }) - console.log('PDF page dimensions:', { width: currentPage.width, height: currentPage.height }) - } catch (error) { - console.error('Error rendering PDF:', error) + } + }, 100); + } + } catch (e) { + console.error('Error accessing iframe content:', e); } - } + }; - fields.filter(f => f.pageIndex === currentPageIndex).forEach(field => { - renderField(field) - }) + canvas.appendChild(iframe); + + console.log('Canvas dimensions:', { + width: canvasWidth, + height: canvasHeight, + scale: currentScale, + }); + console.log('PDF page dimensions:', { + width: currentPage.width, + height: currentPage.height, + }); + } catch (error) { + console.error('Error rendering PDF:', error); + } + } + + fields + .filter((f) => f.pageIndex === currentPageIndex) + .forEach((field) => { + renderField(field); + }); } function updatePageNavigation(): void { - pageIndicator.textContent = `Page ${currentPageIndex + 1} of ${pages.length}` - prevPageBtn.disabled = currentPageIndex === 0 - nextPageBtn.disabled = currentPageIndex === pages.length - 1 + pageIndicator.textContent = `Page ${currentPageIndex + 1} of ${pages.length}`; + prevPageBtn.disabled = currentPageIndex === 0; + nextPageBtn.disabled = currentPageIndex === pages.length - 1; } // Drag and drop handlers for upload area dropZone.addEventListener('dragover', (e) => { - e.preventDefault() - dropZone.classList.add('border-indigo-500', 'bg-gray-600') -}) + e.preventDefault(); + dropZone.classList.add('border-indigo-500', 'bg-gray-600'); +}); dropZone.addEventListener('dragleave', () => { - dropZone.classList.remove('border-indigo-500', 'bg-gray-600') -}) + dropZone.classList.remove('border-indigo-500', 'bg-gray-600'); +}); dropZone.addEventListener('drop', (e) => { - e.preventDefault() - dropZone.classList.remove('border-indigo-500', 'bg-gray-600') - const files = e.dataTransfer?.files - if (files && files.length > 0 && files[0].type === 'application/pdf') { - handlePdfUpload(files[0]) - } -}) + e.preventDefault(); + dropZone.classList.remove('border-indigo-500', 'bg-gray-600'); + const files = e.dataTransfer?.files; + if (files && files.length > 0 && files[0].type === 'application/pdf') { + handlePdfUpload(files[0]); + } +}); pdfFileInput.addEventListener('change', async (e) => { - const file = (e.target as HTMLInputElement).files?.[0] - if (file) { - handlePdfUpload(file) - } -}) + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + handlePdfUpload(file); + } +}); blankPdfBtn.addEventListener('click', () => { - pageSizeSelector.classList.remove('hidden') -}) + pageSizeSelector.classList.remove('hidden'); +}); pageSizeSelect.addEventListener('change', () => { - if (pageSizeSelect.value === 'custom') { - customDimensionsInput.classList.remove('hidden') - } else { - customDimensionsInput.classList.add('hidden') - } -}) + if (pageSizeSelect.value === 'custom') { + customDimensionsInput.classList.remove('hidden'); + } else { + customDimensionsInput.classList.add('hidden'); + } +}); confirmBlankBtn.addEventListener('click', () => { - const selectedSize = pageSizeSelect.value - pageSize = getPageDimensions(selectedSize) + const selectedSize = pageSizeSelect.value; + pageSize = getPageDimensions(selectedSize); - createBlankPage() - switchToPage(0) + createBlankPage(); + switchToPage(0); - // Hide upload area and show tool container - uploadArea.classList.add('hidden') - toolContainer.classList.remove('hidden') - setTimeout(() => createIcons({ icons }), 100) -}) + // Hide upload area and show tool container + uploadArea.classList.add('hidden'); + toolContainer.classList.remove('hidden'); + setTimeout(() => createIcons({ icons }), 100); +}); async function handlePdfUpload(file: File) { + try { + const arrayBuffer = await file.arrayBuffer(); + uploadedPdfDoc = await PDFDocument.load(arrayBuffer); + + // Check for existing fields and update counter + existingFieldNames.clear(); try { - const arrayBuffer = await file.arrayBuffer() - uploadedPdfDoc = await PDFDocument.load(arrayBuffer) + const form = uploadedPdfDoc.getForm(); + const pdfFields = form.getFields(); - // Check for existing fields and update counter - existingFieldNames.clear() - try { - const form = uploadedPdfDoc.getForm() - const pdfFields = form.getFields() + // console.log('📋 Found', pdfFields.length, 'existing fields in uploaded PDF') - // console.log('📋 Found', pdfFields.length, 'existing fields in uploaded PDF') + pdfFields.forEach((field) => { + const name = field.getName(); + existingFieldNames.add(name); // Track all existing field names - pdfFields.forEach(field => { - const name = field.getName() - existingFieldNames.add(name) // Track all existing field names - - if (field instanceof PDFRadioGroup) { - existingRadioGroups.add(name) - } - - // console.log(' Field:', name, '| Type:', field.constructor.name) - - const match = name.match(/([a-zA-Z]+)_(\d+)/) - if (match) { - const num = parseInt(match[2]) - if (!isNaN(num) && num > fieldCounter) { - fieldCounter = num - console.log(' → Updated field counter to:', fieldCounter) - } - } - }) - - // TODO@ALAM: DEBUGGER - // console.log('Field counter after upload:', fieldCounter) - // console.log('Existing field names:', Array.from(existingFieldNames)) - } catch (e) { - console.log('No form fields found or error reading fields:', e) + if (field instanceof PDFRadioGroup) { + existingRadioGroups.add(name); } - uploadedPdfjsDoc = await getPDFDocument({ data: arrayBuffer }).promise + // console.log(' Field:', name, '| Type:', field.constructor.name) - const pageCount = uploadedPdfDoc.getPageCount() - pages = [] - - for (let i = 0; i < pageCount; i++) { - const page = uploadedPdfDoc.getPage(i) - const { width, height } = page.getSize() - - pages.push({ - index: i, - width, - height, - pdfPageData: undefined - }) + const match = name.match(/([a-zA-Z]+)_(\d+)/); + if (match) { + const num = parseInt(match[2]); + if (!isNaN(num) && num > fieldCounter) { + fieldCounter = num; + console.log(' → Updated field counter to:', fieldCounter); + } } + }); - currentPageIndex = 0 - renderCanvas() - updatePageNavigation() - - // Hide upload area and show tool container - uploadArea.classList.add('hidden') - toolContainer.classList.remove('hidden') - - // Init icons - setTimeout(() => createIcons({ icons }), 100) - } catch (error) { - console.error('Error loading PDF:', error) - showModal('Error', 'Error loading PDF file. Please try again with a valid PDF.', 'error') + // TODO@ALAM: DEBUGGER + // console.log('Field counter after upload:', fieldCounter) + // console.log('Existing field names:', Array.from(existingFieldNames)) + } catch (e) { + console.log('No form fields found or error reading fields:', e); } + + uploadedPdfjsDoc = await getPDFDocument({ data: arrayBuffer }).promise; + + const pageCount = uploadedPdfDoc.getPageCount(); + pages = []; + + for (let i = 0; i < pageCount; i++) { + const page = uploadedPdfDoc.getPage(i); + const { width, height } = page.getSize(); + + pages.push({ + index: i, + width, + height, + pdfPageData: undefined, + }); + } + + currentPageIndex = 0; + renderCanvas(); + updatePageNavigation(); + + // Hide upload area and show tool container + uploadArea.classList.add('hidden'); + toolContainer.classList.remove('hidden'); + + // Init icons + setTimeout(() => createIcons({ icons }), 100); + } catch (error) { + console.error('Error loading PDF:', error); + showModal( + 'Error', + 'Error loading PDF file. Please try again with a valid PDF.', + 'error' + ); + } } // Page navigation prevPageBtn.addEventListener('click', () => { - if (currentPageIndex > 0) { - switchToPage(currentPageIndex - 1) - } -}) + if (currentPageIndex > 0) { + switchToPage(currentPageIndex - 1); + } +}); nextPageBtn.addEventListener('click', () => { - if (currentPageIndex < pages.length - 1) { - switchToPage(currentPageIndex + 1) - } -}) + if (currentPageIndex < pages.length - 1) { + switchToPage(currentPageIndex + 1); + } +}); addPageBtn.addEventListener('click', () => { - createBlankPage() - switchToPage(pages.length - 1) -}) + createBlankPage(); + switchToPage(pages.length - 1); +}); resetBtn.addEventListener('click', () => { - if (fields.length > 0 || pages.length > 0) { - if (confirm('Are you sure you want to reset? All your work will be lost.')) { - resetToInitial() - } - } else { - resetToInitial() + if (fields.length > 0 || pages.length > 0) { + if ( + confirm('Are you sure you want to reset? All your work will be lost.') + ) { + resetToInitial(); } -}) + } else { + resetToInitial(); + } +}); // Custom Modal Logic -const errorModal = document.getElementById('errorModal') -const errorModalTitle = document.getElementById('errorModalTitle') -const errorModalMessage = document.getElementById('errorModalMessage') -const errorModalClose = document.getElementById('errorModalClose') +const errorModal = document.getElementById('errorModal'); +const errorModalTitle = document.getElementById('errorModalTitle'); +const errorModalMessage = document.getElementById('errorModalMessage'); +const errorModalClose = document.getElementById('errorModalClose'); -let modalCloseCallback: (() => void) | null = null +let modalCloseCallback: (() => void) | null = null; -function showModal(title: string, message: string, type: 'error' | 'warning' | 'info' = 'error', onClose?: () => void, buttonText: string = 'Close') { - if (!errorModal || !errorModalTitle || !errorModalMessage || !errorModalClose) return +function showModal( + title: string, + message: string, + type: 'error' | 'warning' | 'info' = 'error', + onClose?: () => void, + buttonText: string = 'Close' +) { + if (!errorModal || !errorModalTitle || !errorModalMessage || !errorModalClose) + return; - errorModalTitle.textContent = title - errorModalMessage.textContent = message - errorModalClose.textContent = buttonText + errorModalTitle.textContent = title; + errorModalMessage.textContent = message; + errorModalClose.textContent = buttonText; - modalCloseCallback = onClose || null - errorModal.classList.remove('hidden') + modalCloseCallback = onClose || null; + errorModal.classList.remove('hidden'); } if (errorModalClose) { - errorModalClose.addEventListener('click', () => { - errorModal?.classList.add('hidden') - if (modalCloseCallback) { - modalCloseCallback() - modalCloseCallback = null - } - }) + errorModalClose.addEventListener('click', () => { + errorModal?.classList.add('hidden'); + if (modalCloseCallback) { + modalCloseCallback(); + modalCloseCallback = null; + } + }); } // Close modal on backdrop click if (errorModal) { - errorModal.addEventListener('click', (e) => { - if (e.target === errorModal) { - errorModal.classList.add('hidden') - if (modalCloseCallback) { - modalCloseCallback() - modalCloseCallback = null - } - } - }) + errorModal.addEventListener('click', (e) => { + if (e.target === errorModal) { + errorModal.classList.add('hidden'); + if (modalCloseCallback) { + modalCloseCallback(); + modalCloseCallback = null; + } + } + }); } -initializeGlobalShortcuts() +initializeGlobalShortcuts(); diff --git a/src/pages/form-creator.html b/src/pages/form-creator.html index 00b0a94..762c8b2 100644 --- a/src/pages/form-creator.html +++ b/src/pages/form-creator.html @@ -355,7 +355,9 @@
-