1
0
mirror of https://github.com/Stirling-Tools/Stirling-PDF.git synced 2024-06-26 00:30:27 +02:00

Lots of changes (#70)

Image extraction and conversion to formats 

Multi parallel file execution for all forms so you can input multiple files quickly 

Any file at all pdf using libreoffice, super powerful
Sadly makes docker image larger but worth it 

OCR PDF using ocr my pdf
Works awesomely for adding text to a image

Improved compression using ocr my pdf app

Settings page with custom download options such as 
- open in same window
- open in new window
- download
- download as zip

Update detection in settings page it should show notification if there is a update (very hidden)

UI cleanups

Add other image formats to PDF to Image

Various fies to icons, and pdf.js usage
This commit is contained in:
Anthony Stirling 2023-03-20 21:55:11 +00:00 committed by GitHub
parent 54abb53842
commit a9145fe84c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 82327 additions and 8300 deletions

View File

@ -1,5 +1,58 @@
# Build jbig2enc in a separate stage
FROM debian:bullseye-slim as jbig2enc_builder
RUN apt-get update && \
apt-get install -y --no-install-recommends \
git \
automake \
autoconf \
libtool \
libleptonica-dev \
pkg-config \
ca-certificates \
zlib1g-dev \
make \
g++
RUN git clone https://github.com/agl/jbig2enc && \
cd jbig2enc && \
./autogen.sh && \
./configure && \
make && \
make install
# Main stage
FROM openjdk:17-jdk-slim
# Install necessary dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libreoffice-core \
libreoffice-common \
libreoffice-writer \
libreoffice-calc \
libreoffice-impress \
python3-uno \
python3-pip \
unoconv \
pngquant \
ocrmypdf && \
pip install --user --upgrade ocrmypdf
# Copy the jbig2enc binary from the builder stage
COPY --from=jbig2enc_builder /usr/local/bin/jbig2 /usr/local/bin/jbig2
# Copy the application JAR file
COPY build/libs/*.jar app.jar
# Expose the application port
EXPOSE 8080
# Set environment variables
ENV LOG_LEVEL=INFO
ENTRYPOINT ["java","-jar","/app.jar","-Dlogging.level=${LOG_LEVEL}"]
# Run the application
ENTRYPOINT ["java","-jar","/app.jar","-Dlogging.level=${LOG_LEVEL}"]

49
HowToUseOCR.md Normal file
View File

@ -0,0 +1,49 @@
# OCR Language Packs and Setup
This document provides instructions on how to add additional language packs for the OCR tab in Stirling-PDF, both inside and outside of Docker.
## How does the OCR Work
Stirling-PDF uses OCRmyPDF which in turn uses tesseract for its text recognition.
All credit goes to them for this awesome work!
## Language Packs
Tesseract OCR supports a variety of languages. You can find additional language packs in the Tesseract GitHub repositories:
- [tessdata_fast](https://github.com/tesseract-ocr/tessdata_fast): These language packs are smaller and faster to load, but may provide lower recognition accuracy.
- [tessdata](https://github.com/tesseract-ocr/tessdata): These language packs are larger and provide better recognition accuracy, but may take longer to load.
Depending on your requirements, you can choose the appropriate language pack for your use case. By default Stirling-PDF uses the tessdata_fast eng but this can be replaced.
### Installing Language Packs
1. Download the desired language pack(s) by selecting the `.traineddata` file(s) for the language(s) you need.
2. Place the `.traineddata` files in the Tesseract tessdata directory: `/usr/share/tesseract-ocr/4.00/tessdata`
#### Docker
If you are using Docker, you need to expose the Tesseract tessdata directory as a volume in order to use the additional language packs.
#### Docker Compose
Modify your `docker-compose.yml` file to include the following volume configuration:
```yaml
services:
your_service_name:
image: your_docker_image_name
volumes:
- /usr/share/tesseract-ocr/4.00/tessdata:/location/of/trainingData
```
#### Docker run
Add the following to your existing docker run command
```bash
-v /usr/share/tesseract-ocr/4.00/tessdata:/location/of/trainingData
```
#### Non-Docker
If you are not using Docker, you need to install the OCR components, including the ocrmypdf app.
You can see [OCRmyPDF install guide](https://ocrmypdf.readthedocs.io/en/latest/installation.html)

View File

@ -5,34 +5,34 @@ plugins {
}
group = 'stirling.software'
version = '0.3.4'
version = '0.4.0'
sourceCompatibility = '17'
repositories {
mavenCentral()
maven {
url "https://repo.e-iceblue.com/nexus/content/groups/public/"
name "com.e-iceblue"
}
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'org.apache.pdfbox:pdfbox:2.0.27'
implementation 'org.apache.logging.log4j:log4j-core:2.20.0'
implementation 'e-iceblue:spire.pdf.free:5.1.0'
implementation 'org.apache.poi:poi:5.2.3'
implementation 'org.apache.poi:poi-ooxml:5.2.3'
implementation 'org.apache.logging.log4j:log4j-core:2.20.0'
//general PDF
implementation 'org.apache.pdfbox:pdfbox:2.0.27'
implementation 'com.itextpdf:itextpdf:5.5.13.3'
}
jar {
enabled = false
manifest {
attributes 'Implementation-Title': 'Stirling-PDF',
'Implementation-Version': project.version
}
}
tasks.named('test') {
@ -42,4 +42,3 @@ tasks.named('test') {
task printVersion {
println project.version
}

View File

@ -0,0 +1,94 @@
package stirling.software.SPDF;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class LibreOfficeListener {
private static final LibreOfficeListener INSTANCE = new LibreOfficeListener();
private static final long ACTIVITY_TIMEOUT = 20 * 60 * 1000; // 20 minutes
private static final int LISTENER_PORT = 2002;
private ExecutorService executorService;
private Process process;
private long lastActivityTime;
private LibreOfficeListener() {}
public static LibreOfficeListener getInstance() {
return INSTANCE;
}
public void start() throws IOException {
// Check if the listener is already running
if (process != null && process.isAlive()) {
return;
}
// Start the listener process
process = Runtime.getRuntime().exec("unoconv --listener");
lastActivityTime = System.currentTimeMillis();
// Start a background thread to monitor the activity timeout
executorService = Executors.newSingleThreadExecutor();
executorService.submit(() -> {
while (true) {
long idleTime = System.currentTimeMillis() - lastActivityTime;
if (idleTime >= ACTIVITY_TIMEOUT) {
// If there has been no activity for too long, tear down the listener
process.destroy();
break;
}
try {
Thread.sleep(5000); // Check for inactivity every 5 seconds
} catch (InterruptedException e) {
break;
}
}
});
// Wait for the listener to start up
long startTime = System.currentTimeMillis();
long timeout = 30000; // Timeout after 30 seconds
while (System.currentTimeMillis() - startTime < timeout) {
if (isListenerRunning()) {
lastActivityTime = System.currentTimeMillis();
return;
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} // Check every 1 second
}
}
private boolean isListenerRunning() {
try {
System.out.println("waiting for listener to start");
Socket socket = new Socket();
socket.connect(new InetSocketAddress("localhost", 2002), 1000); // Timeout after 1 second
socket.close();
return true;
} catch (IOException e) {
return false;
}
}
public synchronized void stop() {
// Stop the activity timeout monitor thread
executorService.shutdownNow();
// Stop the listener process
if (process != null && process.isAlive()) {
process.destroy();
}
}
}

View File

@ -0,0 +1,14 @@
package stirling.software.SPDF.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AppConfig {
@Bean(name = "appVersion")
public String appVersion() {
String version = getClass().getPackage().getImplementationVersion();
return (version != null) ? version : "0.3.3";
}
}

View File

@ -1,9 +1,23 @@
package stirling.software.SPDF.controller;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import javax.imageio.ImageIO;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@ -12,16 +26,31 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import com.spire.pdf.PdfCompressionLevel;
import com.spire.pdf.PdfDocument;
import com.spire.pdf.PdfPageBase;
import com.spire.pdf.exporting.PdfImageInfo;
import com.spire.pdf.graphics.PdfBitmap;
import com.itextpdf.text.DocumentException;
import com.itextpdf.text.pdf.PdfReader;
import com.itextpdf.text.pdf.PdfStamper;
import stirling.software.SPDF.utils.ErrorUtils;
import stirling.software.SPDF.utils.PdfUtils;
import stirling.software.SPDF.utils.ProcessExecutor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
//import com.spire.pdf.*;
@Controller
public class CompressController {
@ -33,36 +62,56 @@ public class CompressController {
return "compress-pdf";
}
@PostMapping("/compress-pdf")
public ResponseEntity<byte[]> compressPDF(@RequestParam("fileInput") MultipartFile pdfFile, @RequestParam("imageCompressionLevel") String imageCompressionLevel)
throws IOException {
// Load a sample PDF document
PdfDocument document = new PdfDocument();
document.loadFromBytes(pdfFile.getBytes());
public ResponseEntity<byte[]> optimizePdf(
@RequestParam("fileInput") MultipartFile inputFile,
@RequestParam("optimizeLevel") int optimizeLevel,
@RequestParam(name = "fastWebView", required = false) Boolean fastWebView,
@RequestParam(name = "jbig2Lossy", required = false) Boolean jbig2Lossy) throws IOException, InterruptedException {
// Compress PDF
document.getFileInfo().setIncrementalUpdate(false);
document.setCompressionLevel(PdfCompressionLevel.Best);
// Save the uploaded file to a temporary location
Path tempInputFile = Files.createTempFile("input_", ".pdf");
inputFile.transferTo(tempInputFile.toFile());
// compress PDF Images
for (int i = 0; i < document.getPages().getCount(); i++) {
// Prepare the output file path
Path tempOutputFile = Files.createTempFile("output_", ".pdf");
PdfPageBase page = document.getPages().get(i);
PdfImageInfo[] images = page.getImagesInfo();
if (images != null && images.length > 0)
for (int j = 0; j < images.length; j++) {
PdfImageInfo image = images[j];
PdfBitmap bp = new PdfBitmap(image.getImage());
// bp.setPngDirectToJpeg(true);
bp.setQuality(Integer.valueOf(imageCompressionLevel));
// Prepare the OCRmyPDF command
List<String> command = new ArrayList<>();
command.add("ocrmypdf");
command.add("--optimize");
command.add(String.valueOf(optimizeLevel));
page.replaceImage(j, bp);
}
if (fastWebView != null && fastWebView) {
long fileSize = inputFile.getSize();
long fastWebViewSize = (long) (fileSize * 1.25); // 25% higher than file size
command.add("--fast-web-view");
command.add(String.valueOf(fastWebViewSize));
}
return PdfUtils.pdfDocToWebResponse(document, pdfFile.getOriginalFilename().replaceFirst("[.][^.]+$", "") + "_compressed.pdf");
if (jbig2Lossy != null && jbig2Lossy) {
command.add("--jbig2-lossy");
}
}
command.add(tempInputFile.toString());
command.add(tempOutputFile.toString());
int returnCode = ProcessExecutor.runCommandWithOutputHandling(command);
// Read the optimized PDF file
byte[] pdfBytes = Files.readAllBytes(tempOutputFile);
// Clean up the temporary files
Files.delete(tempInputFile);
Files.delete(tempOutputFile);
// Return the optimized PDF as a response
String outputFilename = inputFile.getOriginalFilename().replaceFirst("[.][^.]+$", "") + "_Optimized.pdf";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
headers.setContentDispositionFormData("attachment", outputFilename);
return ResponseEntity.ok().headers(headers).body(pdfBytes);
}
}

View File

@ -0,0 +1,129 @@
package stirling.software.SPDF.controller;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.image.BufferedImage;
import java.awt.image.RenderedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.zip.Deflater;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import javax.imageio.ImageIO;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
@Controller
public class ExtractImagesController {
private static final Logger logger = LoggerFactory.getLogger(ExtractImagesController.class);
@GetMapping("/extract-images")
public String extractImagesForm(Model model) {
model.addAttribute("currentPage", "extract-images");
return "extract-images";
}
@PostMapping("/extract-images")
public ResponseEntity<Resource> extractImages(@RequestParam("fileInput") MultipartFile file, @RequestParam("format") String format) throws IOException {
System.out.println(System.currentTimeMillis() + "file=" + file.getName() + ", format=" + format);
PDDocument document = PDDocument.load(file.getBytes());
// Create ByteArrayOutputStream to write zip file to byte array
ByteArrayOutputStream baos = new ByteArrayOutputStream();
// Create ZipOutputStream to create zip file
ZipOutputStream zos = new ZipOutputStream(baos);
// Set compression level
zos.setLevel(Deflater.BEST_COMPRESSION);
int imageIndex = 1;
int pageNum = 1;
// Iterate over each page
for (PDPage page : document.getPages()) {
++pageNum;
// Extract images from page
for (COSName name : page.getResources().getXObjectNames()) {
if (page.getResources().isImageXObject(name)) {
PDImageXObject image = (PDImageXObject) page.getResources().getXObject(name);
// Convert image to desired format
RenderedImage renderedImage = image.getImage();
BufferedImage bufferedImage = null;
if (format.equalsIgnoreCase("png")) {
bufferedImage = new BufferedImage(renderedImage.getWidth(), renderedImage.getHeight(),
BufferedImage.TYPE_INT_ARGB);
} else if (format.equalsIgnoreCase("jpeg") || format.equalsIgnoreCase("jpg")) {
bufferedImage = new BufferedImage(renderedImage.getWidth(), renderedImage.getHeight(),
BufferedImage.TYPE_INT_RGB);
} else if (format.equalsIgnoreCase("gif")) {
bufferedImage = new BufferedImage(renderedImage.getWidth(), renderedImage.getHeight(),
BufferedImage.TYPE_BYTE_INDEXED);
}
// Write image to zip file
String imageName = "Image " + imageIndex + " (Page " + pageNum + ")." + format;
ZipEntry zipEntry = new ZipEntry(imageName);
zos.putNextEntry(zipEntry);
Graphics2D g = bufferedImage.createGraphics();
g.drawImage((Image) renderedImage, 0, 0, null);
g.dispose();
// Write image bytes to zip file
ByteArrayOutputStream imageBaos = new ByteArrayOutputStream();
ImageIO.write(bufferedImage, format, imageBaos);
zos.write(imageBaos.toByteArray());
zos.closeEntry();
imageIndex++;
}
}
}
// Close ZipOutputStream and PDDocument
zos.close();
document.close();
// Create ByteArrayResource from byte array
byte[] zipContents = baos.toByteArray();
ByteArrayResource resource = new ByteArrayResource(zipContents);
// Set content disposition header to indicate that the response should be downloaded as a file
HttpHeaders headers = new HttpHeaders();
headers.setContentLength(zipContents.length);
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + file.getOriginalFilename().replaceFirst("[.][^.]+$", "") + "_extracted-images.zip");
// Return ResponseEntity with ByteArrayResource and headers
return ResponseEntity
.status(HttpStatus.OK)
.headers(headers)
.header("Cache-Control", "no-cache")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}
}

View File

@ -0,0 +1,143 @@
package stirling.software.SPDF.controller;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.ModelAndView;
import stirling.software.SPDF.utils.ProcessExecutor;
import java.io.FileOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
//import com.spire.pdf.*;
@Controller
public class OCRController {
private static final Logger logger = LoggerFactory.getLogger(OCRController.class);
@GetMapping("/ocr-pdf")
public ModelAndView ocrPdfPage() {
ModelAndView modelAndView = new ModelAndView("ocr-pdf");
modelAndView.addObject("languages", getAvailableTesseractLanguages());
modelAndView.addObject("currentPage", "ocr-pdf");
return modelAndView;
}
@PostMapping("/ocr-pdf")
public ResponseEntity<byte[]> processPdfWithOCR(@RequestParam("fileInput") MultipartFile inputFile,
@RequestParam("languages") List<String> selectedLanguages,
@RequestParam(name = "sidecar", required = false) Boolean sidecar) throws IOException, InterruptedException {
//--output-type pdfa
if (selectedLanguages == null || selectedLanguages.size() < 1) {
throw new IOException("Please select at least one language.");
}
// Save the uploaded file to a temporary location
Path tempInputFile = Files.createTempFile("input_", ".pdf");
inputFile.transferTo(tempInputFile.toFile());
// Prepare the output file path
Path tempOutputFile = Files.createTempFile("output_", ".pdf");
// Run OCR Command
String languageOption = String.join("+", selectedLanguages);
List<String> command = new ArrayList<>(Arrays.asList("ocrmypdf","--verbose", "2", "--language", languageOption,
tempInputFile.toString(), tempOutputFile.toString()));
String sidecarFile = tempOutputFile.toString().replace(".pdf", ".txt");
if (sidecar != null && sidecar) {
command.add("--sidecar");
command.add(sidecarFile);
}
int returnCode = ProcessExecutor.runCommandWithOutputHandling(command);
// Read the OCR processed PDF file
byte[] pdfBytes = Files.readAllBytes(tempOutputFile);
// Clean up the temporary files
Files.delete(tempInputFile);
// Return the OCR processed PDF as a response
String outputFilename = inputFile.getOriginalFilename().replaceFirst("[.][^.]+$", "") + "_OCR.pdf";
HttpHeaders headers = new HttpHeaders();
if (sidecar != null && sidecar) {
// Create a zip file containing both the PDF and the text file
String outputZipFilename = inputFile.getOriginalFilename().replaceFirst("[.][^.]+$", "") + "_OCR.zip";
Path tempZipFile = Files.createTempFile("output_", ".zip");
try (ZipOutputStream zipOut = new ZipOutputStream(new FileOutputStream(tempZipFile.toFile()))) {
// Add PDF file to the zip
ZipEntry pdfEntry = new ZipEntry(outputFilename);
zipOut.putNextEntry(pdfEntry);
Files.copy(tempOutputFile, zipOut);
zipOut.closeEntry();
// Add text file to the zip
ZipEntry txtEntry = new ZipEntry(sidecarFile);
zipOut.putNextEntry(txtEntry);
Files.copy(Paths.get(sidecarFile), zipOut);
zipOut.closeEntry();
}
byte[] zipBytes = Files.readAllBytes(tempZipFile);
// Clean up the temporary zip file
Files.delete(tempZipFile);
Files.delete(tempOutputFile);
Files.delete(Paths.get(sidecarFile));
// Return the zip file containing both the PDF and the text file
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
headers.setContentDispositionFormData("attachment", outputZipFilename);
return ResponseEntity.ok().headers(headers).body(zipBytes);
} else {
// Return the OCR processed PDF as a response
Files.delete(tempOutputFile);
headers.setContentType(MediaType.APPLICATION_PDF);
headers.setContentDispositionFormData("attachment", outputFilename);
return ResponseEntity.ok().headers(headers).body(pdfBytes);
}
}
public List<String> getAvailableTesseractLanguages() {
String tessdataDir = "/usr/share/tesseract-ocr/4.00/tessdata";
File[] files = new File(tessdataDir).listFiles();
if (files == null) {
return Collections.emptyList();
}
return Arrays.stream(files)
.filter(file -> file.getName().endsWith(".traineddata"))
.map(file -> file.getName().replace(".traineddata", ""))
.filter(lang -> !lang.equalsIgnoreCase("osd"))
.collect(Collectors.toList());
}
}

View File

@ -10,7 +10,7 @@ import org.springframework.web.bind.annotation.GetMapping;
public class PdfController {
private static final Logger logger = LoggerFactory.getLogger(PdfController.class);
@GetMapping("/home")
public String root(Model model) {
return "redirect:/";
@ -22,4 +22,6 @@ public class PdfController {
return "home";
}
}

View File

@ -49,7 +49,6 @@ public class RearrangePagesPDFController {
int pageIndex = pagesToRemove.get(i);
document.removePage(pageIndex);
}
return PdfUtils.pdfDocToWebResponse(document, pdfFile.getOriginalFilename().replaceFirst("[.][^.]+$", "") + "_removed_pages.pdf");
}

View File

@ -134,7 +134,7 @@ public class SplitPDFController {
ByteArrayResource resource = new ByteArrayResource(data);
new File("split_documents.zip").delete();
// return the Resource in the response
return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=split_documents.zip").contentType(MediaType.APPLICATION_OCTET_STREAM)
return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + file.getOriginalFilename().replaceFirst("[.][^.]+$", "") + "_split.zip").contentType(MediaType.APPLICATION_OCTET_STREAM)
.contentLength(resource.contentLength()).body(resource);
}
}

View File

@ -78,7 +78,7 @@ public class ConvertImgPDFController {
} else {
ByteArrayResource resource = new ByteArrayResource(result);
// return the Resource in the response
return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=converted_documents.zip").contentType(MediaType.APPLICATION_OCTET_STREAM)
return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename="+ file.getOriginalFilename().replaceFirst("[.][^.]+$", "") + "_convertedToImages.zip").contentType(MediaType.APPLICATION_OCTET_STREAM)
.contentLength(resource.contentLength()).body(resource);
}
}

View File

@ -0,0 +1,82 @@
package stirling.software.SPDF.controller.converters;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.ModelAndView;
import stirling.software.SPDF.LibreOfficeListener;
import stirling.software.SPDF.utils.PdfUtils;
import stirling.software.SPDF.utils.ProcessExecutor;
@Controller
public class ConvertOfficeController {
@GetMapping("/file-to-pdf")
public String convertToPdfForm(Model model) {
model.addAttribute("currentPage", "file-to-pdf");
return "convert/file-to-pdf";
}
@PostMapping("/file-to-pdf")
public ResponseEntity<byte[]> processPdfWithOCR(@RequestParam("fileInput") MultipartFile inputFile) throws IOException, InterruptedException {
//unused but can start server instance if startup time is to long
//LibreOfficeListener.getInstance().start();
byte[] pdfByteArray = convertToPdf(inputFile);
return PdfUtils.bytesToWebResponse(pdfByteArray, inputFile.getOriginalFilename().replaceFirst("[.][^.]+$", "") + "_convertedToPDF.pdf");
}
public byte[] convertToPdf(MultipartFile inputFile) throws IOException, InterruptedException {
// Save the uploaded file to a temporary location
Path tempInputFile = Files.createTempFile("input_", "." + getFileExtension(inputFile.getOriginalFilename()));
inputFile.transferTo(tempInputFile.toFile());
// Prepare the output file path
Path tempOutputFile = Files.createTempFile("output_", ".pdf");
// Run the LibreOffice command
List<String> command = new ArrayList<>(Arrays.asList("unoconv", "-vvv",
"-f",
"pdf",
"-o",
tempOutputFile.toString(),
tempInputFile.toString()));
int returnCode = ProcessExecutor.runCommandWithOutputHandling(command);
// Read the converted PDF file
byte[] pdfBytes = Files.readAllBytes(tempOutputFile);
// Clean up the temporary files
Files.delete(tempInputFile);
Files.delete(tempOutputFile);
return pdfBytes;
}
private String getFileExtension(String fileName) {
int dotIndex = fileName.lastIndexOf('.');
if (dotIndex == -1) {
return "";
}
return fileName.substring(dotIndex + 1);
}
}

View File

@ -1,81 +0,0 @@
package stirling.software.SPDF.controller.converters;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import org.apache.poi.ss.usermodel.Color;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import com.itextpdf.text.BaseColor;
import com.itextpdf.text.Document;
import com.itextpdf.text.DocumentException;
import com.itextpdf.text.Element;
import com.itextpdf.text.pdf.PdfPCell;
import com.itextpdf.text.pdf.PdfPTable;
import com.itextpdf.text.pdf.PdfWriter;
import stirling.software.SPDF.utils.PdfUtils;
@Controller
public class ConvertXlsxController {
@GetMapping("/xlsx-to-pdf")
public String cinvertToPDF(Model model) {
model.addAttribute("currentPage", "xlsx-to-pdf");
return "convert/xlsx-to-pdf";
}
@PostMapping("/xlsx-to-pdf")
public ResponseEntity<byte[]> convertToPDF(@RequestParam("fileInput") MultipartFile xlsx) throws IOException, DocumentException{
// Load Excel file
Workbook workbook = WorkbookFactory.create(xlsx.getInputStream());
// Create PDF document
Document document = new Document();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
PdfWriter.getInstance(document, outputStream);
document.open();
// Convert each sheet in Excel to a separate page in PDF
for (int i = 0; i < workbook.getNumberOfSheets(); i++) {
PdfPTable table = new PdfPTable(workbook.getSheetAt(i).getRow(0).getPhysicalNumberOfCells());
for (int row = 0; row < workbook.getSheetAt(i).getPhysicalNumberOfRows(); row++) {
for (int cell = 0; cell < workbook.getSheetAt(i).getRow(row).getPhysicalNumberOfCells(); cell++) {
PdfPCell pdfCell = new PdfPCell();
pdfCell.addElement(new com.itextpdf.text.Paragraph(workbook.getSheetAt(i).getRow(row).getCell(cell).toString()));
// Copy cell style, borders, and background color
pdfCell.setBorderColor(new BaseColor(workbook.getSheetAt(i).getRow(row).getCell(cell).getCellStyle().getBottomBorderColor()));
pdfCell.setBorderColor(new BaseColor(workbook.getSheetAt(i).getRow(row).getCell(cell).getCellStyle().getTopBorderColor()));
pdfCell.setBorderColor(new BaseColor(workbook.getSheetAt(i).getRow(row).getCell(cell).getCellStyle().getLeftBorderColor()));
pdfCell.setBorderColor(new BaseColor(workbook.getSheetAt(i).getRow(row).getCell(cell).getCellStyle().getRightBorderColor()));
Short bc = workbook.getSheetAt(i).getRow(row).getCell(cell).getCellStyle().getFillBackgroundColor();
pdfCell.setBackgroundColor(new BaseColor(bc));
table.addCell(pdfCell);
}
}
document.add(table);
}
// Close document and output stream
document.close();
outputStream.flush();
outputStream.close();
return PdfUtils.boasToWebResponse(outputStream, xlsx.getOriginalFilename().replaceFirst("[.][^.]+$", "") + "_convertedToPDF.pdf");
// Close document and input stream
}
}

View File

@ -2,12 +2,19 @@ package stirling.software.SPDF.controller.security;
import java.awt.Color;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationMarkup;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.util.Matrix;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
@ -18,6 +25,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.SPDF.utils.PdfUtils;
import stirling.software.SPDF.utils.WatermarkRemover;
@Controller
public class WatermarkController {
@ -28,6 +36,12 @@ public class WatermarkController {
return "security/add-watermark";
}
@GetMapping("/remove-watermark")
public String removeWatermarkForm(Model model) {
model.addAttribute("currentPage", "remove-watermark");
return "security/remove-watermark";
}
@PostMapping("/add-watermark")
public ResponseEntity<byte[]> addWatermark(@RequestParam("fileInput") MultipartFile pdfFile, @RequestParam("watermarkText") String watermarkText,
@RequestParam(defaultValue = "30", name = "fontSize") float fontSize, @RequestParam(defaultValue = "0", name = "rotation") float rotation,
@ -71,4 +85,62 @@ public class WatermarkController {
}
return PdfUtils.pdfDocToWebResponse(document, pdfFile.getOriginalFilename().replaceFirst("[.][^.]+$", "") + "_watermarked.pdf");
}
@PostMapping("/remove-watermark")
public ResponseEntity<byte[]> removeWatermark(@RequestParam("fileInput") MultipartFile pdfFile, @RequestParam("watermarkText") String watermarkText) throws Exception {
// Load the input PDF
PDDocument document = PDDocument.load(pdfFile.getInputStream());
// Create a new PDF document for the output
PDDocument outputDocument = new PDDocument();
// Loop through the pages
int numPages = document.getNumberOfPages();
for (int i = 0; i < numPages; i++) {
PDPage page = document.getPage(i);
// Process the content stream to remove the watermark text
WatermarkRemover editor = new WatermarkRemover(watermarkText) {};
editor.processPage(page);
editor.processPage(page);
// Add the page to the output document
outputDocument.addPage(page);
}
for (PDPage page : outputDocument.getPages()) {
List<PDAnnotation> annotations = page.getAnnotations();
List<PDAnnotation> annotationsToRemove = new ArrayList<>();
for (PDAnnotation annotation : annotations) {
if (annotation instanceof PDAnnotationMarkup) {
PDAnnotationMarkup markup = (PDAnnotationMarkup) annotation;
String contents = markup.getContents();
if (contents != null && contents.contains(watermarkText)) {
annotationsToRemove.add(markup);
}
}
}
annotations.removeAll(annotationsToRemove);
}
PDDocumentCatalog catalog = outputDocument.getDocumentCatalog();
PDAcroForm acroForm = catalog.getAcroForm();
if (acroForm != null) {
List<PDField> fields = acroForm.getFields();
for (PDField field : fields) {
String fieldValue = field.getValueAsString();
if (fieldValue.contains(watermarkText)) {
field.setValue(fieldValue.replace(watermarkText, ""));
}
}
}
return PdfUtils.pdfDocToWebResponse(outputDocument, "removed.pdf");
}
}

View File

@ -32,7 +32,6 @@ import org.springframework.http.ResponseEntity;
import com.itextpdf.text.Document;
import com.itextpdf.text.DocumentException;
import com.itextpdf.text.pdf.PdfWriter;
import com.spire.pdf.PdfDocument;
public class PdfUtils {
@ -172,16 +171,7 @@ public class PdfUtils {
return PdfUtils.boasToWebResponse(baos, docName);
}
public static ResponseEntity<byte[]> pdfDocToWebResponse(PdfDocument document, String docName) throws IOException {
// Open Byte Array and save document to it
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.saveToStream(baos);
// Close the document
document.close();
return PdfUtils.boasToWebResponse(baos, docName);
}
public static ResponseEntity<byte[]> pdfDocToWebResponse(PDDocument document, String docName) throws IOException {

View File

@ -0,0 +1,69 @@
package stirling.software.SPDF.utils;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.Arrays;
import java.util.List;
import java.io.BufferedReader;
import java.util.ArrayList;
public class ProcessExecutor {
public static int runCommandWithOutputHandling(List<String> command) throws IOException, InterruptedException {
ProcessBuilder processBuilder = new ProcessBuilder(command);
Process process = processBuilder.start();
// Read the error stream and standard output stream concurrently
List<String> errorLines = new ArrayList<>();
List<String> outputLines = new ArrayList<>();
Thread errorReaderThread = new Thread(() -> {
try (BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = errorReader.readLine()) != null) {
errorLines.add(line);
}
} catch (IOException e) {
e.printStackTrace();
}
});
Thread outputReaderThread = new Thread(() -> {
try (BufferedReader outputReader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = outputReader.readLine()) != null) {
outputLines.add(line);
}
} catch (IOException e) {
e.printStackTrace();
}
});
errorReaderThread.start();
outputReaderThread.start();
// Wait for the conversion process to complete
int exitCode = process.waitFor();
// Wait for the reader threads to finish
errorReaderThread.join();
outputReaderThread.join();
if (outputLines.size() > 0) {
String outputMessage = String.join("\n", outputLines);
System.out.println("Command output:\n" + outputMessage);
}
if (errorLines.size() > 0) {
String errorMessage = String.join("\n", errorLines);
System.out.println("Command error output:\n" + errorMessage);
if (exitCode != 0) {
throw new IOException("Command process failed with exit code " + exitCode + ". Error message: " + errorMessage);
}
}
return exitCode;
}
}

View File

@ -0,0 +1,69 @@
package stirling.software.SPDF.utils;
import java.io.IOException;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.pdfbox.contentstream.PDFStreamEngine;
import org.apache.pdfbox.contentstream.operator.Operator;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSString;
public class WatermarkRemover extends PDFStreamEngine {
private final String watermarkText;
private final Pattern pattern;
public WatermarkRemover(String watermarkText) {
this.watermarkText = watermarkText;
this.pattern = Pattern.compile(Pattern.quote(watermarkText));
}
@Override
protected void processOperator(Operator operator, List<COSBase> operands) throws IOException {
String operation = operator.getName();
boolean processText = false;
if ("Tj".equals(operation) || "TJ".equals(operation) || "'".equals(operation) || "\"".equals(operation)) {
processText = true;
}
if (processText) {
for(int j = 0 ; j < operands.size(); ++j) {
COSBase operand = operands.get(j);
if (operand instanceof COSString) {
COSString cosString = (COSString) operand;
String string = cosString.getString();
Matcher matcher = pattern.matcher(string);
if (matcher.find()) {
string = matcher.replaceAll("");
cosString.setValue(string.getBytes());
}
} else if (operand instanceof COSArray) {
COSArray array = (COSArray) operand;
for (int i = 0; i < array.size(); i++) {
COSBase item = array.get(i);
if (item instanceof COSString) {
COSString cosString = (COSString) item;
String string = cosString.getString();
Matcher matcher = pattern.matcher(string);
if (matcher.find()) {
System.out.println("operation =" + operation);
System.out.println("1 =" + string);
string = matcher.replaceAll("");
cosString.setValue(string.getBytes());
array.set(i, cosString);
operands.set(j, array);
}
}
}
}
}
}
super.processOperator(operator, operands);
}
}

View File

@ -14,4 +14,6 @@ server.error.path=/error
server.error.whitelabel.enabled=false
server.error.include-stacktrace=always
server.error.include-exception=true
server.error.include-message=always
server.error.include-message=always
server.servlet.session.tracking-modes=cookie

View File

@ -0,0 +1,11 @@
fileToPDF.fileTypesList=Microsoft Word: (DOC, DOCX, DOT, DOTX) <br> \
Microsoft Excel: (CSV, XLS, XLSX, XLT, XLTX, SLK, DIF) <br> \
Microsoft PowerPoint: (PPT, PPTX) <br> \
OpenDocument Formats: (ODT, OTT, ODS, OTS, ODP, OTP, ODG, OTG) <br> \
Plain Text: (TXT, TEXT, XML) <br> \
Rich Text Format: (RTF) <br> \
Images: (BMP, GIF, JPEG, PNG, TIF, PBM, PGM, PPM, RAS, XBM, XPM, SVG, SVM, WMF) <br> \
HTML: (HTML) <br> \
Lotus Word Pro: (LWP) <br> \
StarOffice formats: (SDA, SDC, SDD, SDW, STC, STD, STI, STW, SXD, SXG, SXI, SXW) <br> \
Other formats: (DBF, FODS, VSD, VOR, VOR3, VOR4, UOP, PCT, PS, PDF)

View File

@ -19,6 +19,8 @@ goToPage=اذهب
true=\u0635\u062D\u064A\u062D
false=\u062E\u0637\u0623
unknown=\u063A\u064A\u0631 \u0645\u0639\u0631\u0648\u0641
save=\u062D\u0641\u0638
close=\u0625\u063A\u0644\u0627\u0642
#############
# HOME-PAGE #
@ -75,8 +77,48 @@ home.compressPdfs.desc=ضغط ملفات PDF لتقليل حجم الملف.
home.changeMetadata.title = \u062A\u063A\u064A\u064A\u0631 \u0627\u0644\u0628\u064A\u0627\u0646\u0627\u062A \u0627\u0644\u0648\u0635\u0641\u064A\u0629
home.changeMetadata.desc = \u062A\u063A\u064A\u064A\u0631 / \u0625\u0632\u0627\u0644\u0629 / \u0625\u0636\u0627\u0641\u0629 \u0628\u064A\u0627\u0646\u0627\u062A \u0623\u0648\u0644\u064A\u0629 \u0645\u0646 \u0645\u0633\u062A\u0646\u062F PDF
home.xlsToPdf.title = \u062A\u062D\u0648\u064A\u0644 Excel (Xls) \u0625\u0644\u0649 PDF
home.xlsToPdf.desc = \u0642\u0645 \u0628\u062A\u062D\u0648\u064A\u0644 \u0645\u0633\u062A\u0646\u062F Excel (xls \u060C xlsx) \u0625\u0644\u0649 PDF.
home.fileToPDF.title=\u062A\u062D\u0648\u064A\u0644 \u0627\u0644\u0645\u0644\u0641 \u0625\u0644\u0649 PDF
home.fileToPDF.desc=\u062A\u062D\u0648\u064A\u0644 \u0623\u064A \u0645\u0644\u0641 \u062A\u0642\u0631\u064A\u0628\u0627 \u0625\u0644\u0649 PDF (DOCX \u0648PNG \u0648XLS \u0648PPT \u0648TXT \u0648\u0627\u0644\u0645\u0632\u064A\u062F)
home.ocr.title=\u062A\u0634\u063A\u064A\u0644 OCR \u0639\u0644\u0649 PDF
home.ocr.desc=\u0645\u0633\u062D \u0648\u0627\u0643\u062A\u0634\u0627\u0641 \u0627\u0644\u0646\u0635 \u0645\u0646 \u0627\u0644\u0635\u0648\u0631 \u062F\u0627\u062E\u0644 PDF \u0648\u0625\u0639\u0627\u062F\u0629 \u0625\u0636\u0627\u0641\u062A\u0647 \u0643\u0646\u0635.
home.extractImages.title=\u0627\u0633\u062A\u062E\u0631\u0627\u062C \u0627\u0644\u0635\u0648\u0631
home.extractImages.desc=\u064A\u0633\u062A\u062E\u0631\u062C \u062C\u0645\u064A\u0639 \u0627\u0644\u0635\u0648\u0631 \u0645\u0646 \u0645\u0644\u0641 PDF \u0648\u064A\u062D\u0641\u0638\u0647\u0627 \u0641\u064A \u0627\u0644\u0631\u0645\u0632 \u0627\u0644\u0628\u0631\u064A\u062F\u064A
navbar.settings = \u0625\u0639\u062F\u0627\u062F\u0627\u062A
settings.title=\u0627\u0644\u0625\u0639\u062F\u0627\u062F\u0627\u062A
settings.update = \u0627\u0644\u062A\u062D\u062F\u064A\u062B \u0645\u062A\u0627\u062D
settings.appVersion = \u0625\u0635\u062F\u0627\u0631 \u0627\u0644\u062A\u0637\u0628\u064A\u0642:
settings.downloadOption.title=\u062A\u062D\u062F\u064A\u062F \u062E\u064A\u0627\u0631 \u0627\u0644\u062A\u0646\u0632\u064A\u0644 (\u0644\u0644\u062A\u0646\u0632\u064A\u0644\u0627\u062A \u0630\u0627\u062A \u0627\u0644\u0645\u0644\u0641 \u0627\u0644\u0648\u0627\u062D\u062F \u063A\u064A\u0631 \u0627\u0644\u0645\u0636\u063A\u0648\u0637):
settings.downloadOption.1=\u0641\u062A\u062D \u0641\u064A \u0646\u0641\u0633 \u0627\u0644\u0646\u0627\u0641\u0630\u0629
settings.downloadOption.2=\u0641\u062A\u062D \u0641\u064A \u0646\u0627\u0641\u0630\u0629 \u062C\u062F\u064A\u062F\u0629
settings.downloadOption.3=\u062A\u0646\u0632\u064A\u0644 \u0627\u0644\u0645\u0644\u0641
settings.zip=\u0645\u0644\u0641\u0627\u062A \u0627\u0644\u062A\u0646\u0632\u064A\u0644 \u0627\u0644\u0645\u062A\u0639\u062F\u062F \u0627\u0644\u0645\u0636\u063A\u0648\u0637\u0629
#OCR
OCR.title = OCR
ocr.header=OCR (\u0627\u0644\u062A\u0639\u0631\u0641 \u0627\u0644\u0636\u0648\u0626\u064A \u0639\u0644\u0649 \u0627\u0644\u0623\u062D\u0631\u0641)
ocr.selectText.1=\u062A\u062D\u062F\u064A\u062F \u0627\u0644\u0644\u063A\u0627\u062A \u0627\u0644\u062A\u064A \u0633\u064A\u062A\u0645 \u0627\u0643\u062A\u0634\u0627\u0641\u0647\u0627 \u062F\u0627\u062E\u0644 PDF (\u0627\u0644\u0644\u063A\u0627\u062A \u0627\u0644\u0645\u062F\u0631\u062C\u0629 \u0647\u064A \u062A\u0644\u0643 \u0627\u0644\u0645\u0643\u062A\u0634\u0641\u0629 \u062D\u0627\u0644\u064A\u0627):
ocr.selectText.2=\u0625\u0646\u062A\u0627\u062C \u0645\u0644\u0641 \u0646\u0635\u064A \u064A\u062D\u062A\u0648\u064A \u0639\u0644\u0649 \u0646\u0635 OCR \u062C\u0646\u0628\u0627 \u0625\u0644\u0649 \u062C\u0646\u0628 \u0645\u0639 \u0645\u0644\u0641 PDF OCR'ed
ocr.help=\u064A\u0631\u062C\u0649 \u0642\u0631\u0627\u0621\u0629 \u0647\u0630\u0647 \u0627\u0644\u0648\u062B\u0627\u0626\u0642 \u062D\u0648\u0644 \u0643\u064A\u0641\u064A\u0629 \u0627\u0633\u062A\u062E\u062F\u0627\u0645 \u0647\u0630\u0627 \u0644\u0644\u063A\u0627\u062A \u0623\u062E\u0631\u0649 \u0648 / \u0623\u0648 \u0627\u0644\u0627\u0633\u062A\u062E\u062F\u0627\u0645 \u0644\u064A\u0633 \u0641\u064A \u0639\u0627\u0645\u0644 \u0627\u0644\u0625\u0631\u0633\u0627\u0621
ocr.credit=\u062A\u0633\u062A\u062E\u062F\u0645 \u0647\u0630\u0647 \u0627\u0644\u062E\u062F\u0645\u0629 OCRmyPDF \u0648 Tesseract \u0644 OCR.
ocr.submit = \u0645\u0639\u0627\u0644\u062C\u0629 PDF \u0628\u0627\u0633\u062A\u062E\u062F\u0627\u0645 OCR
extractImages.title=\u0627\u0633\u062A\u062E\u0631\u0627\u062C \u0627\u0644\u0635\u0648\u0631
extractImages.header=\u0627\u0633\u062A\u062E\u0631\u0627\u062C \u0627\u0644\u0635\u0648\u0631
extractImages.selectText=\u062D\u062F\u062F \u062A\u0646\u0633\u064A\u0642 \u0627\u0644\u0635\u0648\u0631\u0629 \u0644\u062A\u062D\u0648\u064A\u0644 \u0627\u0644\u0635\u0648\u0631 \u0627\u0644\u0645\u0633\u062A\u062E\u0631\u062C\u0629 \u0625\u0644\u0649
extractImages.submit=\u0627\u0633\u062A\u062E\u0631\u0627\u062C
#File \u0625\u0644\u0649 PDF
fileToPDF.title=\u0645\u0644\u0641 \u0625\u0644\u0649 PDF
fileToPDF.header=\u062A\u062D\u0648\u064A\u0644 \u0623\u064A \u0645\u0644\u0641 \u0625\u0644\u0649 PDF
fileToPDF.credit=\u062A\u0633\u062A\u062E\u062F\u0645 \u0647\u0630\u0647 \u0627\u0644\u062E\u062F\u0645\u0629 \u0644\u064A\u0628\u0631 \u0623\u0648\u0641\u064A\u0633 \u0648\u0623\u0648\u0646\u0648\u0643\u0648\u0646\u0641 \u0644\u062A\u062D\u0648\u064A\u0644 \u0627\u0644\u0645\u0644\u0641\u0627\u062A.
fileToPDF.supportedFileTypes=\u064A\u062C\u0628 \u0623\u0646 \u062A\u062A\u0636\u0645\u0646 \u0623\u0646\u0648\u0627\u0639 \u0627\u0644\u0645\u0644\u0641\u0627\u062A \u0627\u0644\u0645\u062F\u0639\u0648\u0645\u0629 \u0645\u0627 \u064A\u0644\u064A \u0648\u0644\u0643\u0646 \u0644\u0644\u062D\u0635\u0648\u0644 \u0639\u0644\u0649 \u0642\u0627\u0626\u0645\u0629 \u0645\u062D\u062F\u062B\u0629 \u0643\u0627\u0645\u0644\u0629 \u0628\u0627\u0644\u062A\u0646\u0633\u064A\u0642\u0627\u062A \u0627\u0644\u0645\u062F\u0639\u0648\u0645\u0629 \u060C \u064A\u0631\u062C\u0649 \u0627\u0644\u0631\u062C\u0648\u0639 \u0625\u0644\u0649 \u0648\u062B\u0627\u0626\u0642 LibreOffice
fileToPDF.submit=\u062A\u062D\u0648\u064A\u0644 \u0625\u0644\u0649 PDF
#Add image
addImage.title=إضافة صورة

View File

@ -15,6 +15,8 @@ goToPage=Los
true=Wahr
false=Falsch
unknown=Unbekannt
save=Speichern
close=Schließen
#############
# HOME-PAGE #
@ -71,8 +73,51 @@ home.compressPdfs.desc=PDF komprimieren um die Dateigröße zu reduzieren.
home.changeMetadata.title=Metadaten ändern
home.changeMetadata.desc=Ändern/Entfernen/Hinzufügen von Metadaten aus einem PDF-Dokument
home.xlsToPdf.title=Excel (Xls) in PDF
home.xlsToPdf.desc=Konvertiere ein Excel-Dokument (xls, xlsx) in PDF.
home.fileToPDF.title=Datei in PDF konvertieren
home.fileToPDF.desc=Konvertieren Sie nahezu jede Datei in PDF (DOCX, PNG, XLS, PPT, TXT und mehr)
home.ocr.title=OCR auf PDF ausführen
home.ocr.desc=Scannt und erkennt Text aus Bildern in einer PDF-Datei und fügt ihn erneut als Text hinzu.
home.extractImages.title=Bilder extrahieren
home.extractImages.desc=Extrahiert alle Bilder aus einer PDF-Datei und speichert sie als Zip-Datei
navbar.settings=Einstellungen
settings.title=Einstellungen
settings.update=Update verfügbar
settings.appVersion=App-Version:
settings.downloadOption.title=Download-Option wählen (für einzelne Dateien, die keine Zip-Downloads sind):
settings.downloadOption.1=Im selben Fenster öffnen
settings.downloadOption.2=In neuem Fenster öffnen
settings.downloadOption.3=Datei herunterladen
settings.zip=Dateien mit mehrfachem Download zippen
#OCR
ocr.title=OCR
ocr.header=OCR (Optische Zeichenerkennung)
ocr.selectText.1=Wählen Sie die Sprachen aus, die in der PDF-Datei erkannt werden sollen (die aufgelisteten sind die aktuell erkannten):
ocr.selectText.2=Textdatei mit OCR-Text neben der OCR-PDF-Datei erstellen
ocr.help=Bitte lesen Sie diese Dokumentation, um zu erfahren, wie Sie dies für andere Sprachen verwenden und/oder nicht in Docker verwenden können
ocr.credit=Dieser Dienst verwendet OCRmyPDF und Tesseract für OCR.
ocr.submit=PDF mit OCR verarbeiten
extractImages.title=Bilder extrahieren
extractImages.header=Bilder extrahieren
extractImages.selectText=Wählen Sie das Bildformat aus, in das extrahierte Bilder konvertiert werden sollen
extractImages.submit=Extrahieren
#File to PDF
fileToPDF.title=Datei in PDF
fileToPDF.header=Beliebige Dateien in PDF konvertieren
fileToPDF.credit=Dieser Dienst verwendet LibreOffice und Unoconv für die Dateikonvertierung.
fileToPDF.supportedFileTypes=Unterstützte Dateitypen sollten die folgenden enthalten, eine vollständige aktualisierte Liste der unterstützten Formate finden Sie jedoch in der LibreOffice-Dokumentation
fileToPDF.submit=In PDF konvertieren
#Add image
addImage.title=Bild hinzufügen

View File

@ -15,6 +15,9 @@ goToPage=Go
true=True
false=False
unknown=Unknown
save=Save
close=Close
#############
# HOME-PAGE #
#############
@ -70,20 +73,68 @@ home.compressPdfs.desc=Compress PDFs to reduce their file size.
home.changeMetadata.title=Change Metadata
home.changeMetadata.desc=Change/Remove/Add metadata from a PDF document
home.xlsToPdf.title=Excel (Xls) to PDF
home.xlsToPdf.desc=Convert a Excel document (xls, xlsx) to PDF.
home.fileToPDF.title=Convert file to PDF
home.fileToPDF.desc=Convert nearly any file to PDF (DOCX, PNG, XLS, PPT, TXT and more)
home.ocr.title=Run OCR on PDF
home.ocr.desc=Scans and detects text from images within a PDF and re-adds it as text.
home.extractImages.title=Extract Images
home.extractImages.desc=Extracts all images from a PDF and saves them to zip
navbar.settings=Settings
settings.title=Settings
settings.update=Update available
settings.appVersion=App Version:
settings.downloadOption.title=Choose download option (For single file non zip downloads):
settings.downloadOption.1=Open in same window
settings.downloadOption.2=Open in new window
settings.downloadOption.3=Download file
settings.zip=Zip multi-download files
#OCR
ocr.title=OCR
ocr.header=OCR (Optical Character Recognition)
ocr.selectText.1=Select languages that are to be detected within the PDF (Ones listed are the ones currently detected):
ocr.selectText.2=Produce text file containing OCR text alongside the OCR'ed PDF
ocr.help=Please read this documentation on how to use this for other languages and/or use not in docker
ocr.credit=This service uses OCRmyPDF and Tesseract for OCR.
ocr.submit=Process PDF with OCR
extractImages.title=Extract Images
extractImages.header=Extract Images
extractImages.selectText=Select image format to convert extracted images to
extractImages.submit=Extract
#File to PDF
fileToPDF.title=File to PDF
fileToPDF.header=Convert any file to PDF
fileToPDF.credit=This service uses LibreOffice and Unoconv for file conversion.
fileToPDF.supportedFileTypes=Supported file types should include the below however for a full updated list of supported formats, please refer to the LibreOffice documentation
fileToPDF.submit=Convert to PDF
#compress
compress.title=Compress
compress.header=Compress PDF
compress.credit=This service uses OCRmyPDF for PDF Compress/Optimisation.
compress.selectText.1=Optimization level:
compress.selectText.2=0 (No optimization)
compress.selectText.3=1 (Default, lossless optimization)
compress.selectText.4=2 (Lossy optimization)
compress.selectText.5=3 (Lossy optimization, more aggressive)
compress.selectText.6=Enable fast web view (linearize PDF)
compress.selectText.7=Enable lossy JBIG2 encoding
compress.submit=Compress
#Add image
addImage.title=Add Image
addImage.header=Add image to PDF (Work in progress)
addImage.submit=Add image
#compress
compress.title=Compress
compress.header=Compress PDF
compress.compressLevel=Value between 1 and 100 (1 being most reduced)
compress.submit=Compress
#merge
merge.title=Merge

View File

@ -15,6 +15,9 @@ goToPage=Go
true=True
false=False
unknown=Unknown
save=Save
close=Close
#############
# HOME-PAGE #
#############
@ -70,8 +73,47 @@ home.compressPdfs.desc=Compress PDFs to reduce their file size.
home.changeMetadata.title=Change Metadata
home.changeMetadata.desc=Change/Remove/Add metadata from a PDF document
home.xlsToPdf.title=Excel (Xls) to PDF
home.xlsToPdf.desc=Convert a Excel document (xls, xlsx) to PDF.
home.fileToPDF.title=Convert file to PDF
home.fileToPDF.desc=Convert nearly any file to PDF (DOCX, PNG, XLS, PPT, TXT and more)
home.ocr.title=Run OCR on PDF
home.ocr.desc=Scans and detects text from images within a PDF and re-adds it as text.
home.extractImages.title=Extract Images
home.extractImages.desc=Extracts all images from a PDF and saves them to zip
navbar.settings=Settings
settings.title=Settings
settings.update=Update available
settings.appVersion=App Version:
settings.downloadOption.title=Choose download option (For single file non zip downloads):
settings.downloadOption.1=Open in same window
settings.downloadOption.2=Open in new window
settings.downloadOption.3=Download file
settings.zip=Zip multi-download files
#OCR
ocr.title=OCR
ocr.header=OCR (Optical Character Recognition)
ocr.selectText.1=Select languages that are to be detected within the PDF (Ones listed are the ones currently detected):
ocr.selectText.2=Produce text file containing OCR text alongside the OCR'ed PDF
ocr.help=Please read this documentation on how to use this for other languages and/or use not in docker
ocr.credit=This service uses OCRmyPDF and Tesseract for OCR.
ocr.submit=Process PDF with OCR
extractImages.title=Extract Images
extractImages.header=Extract Images
extractImages.selectText=Select image format to convert extracted images to
extractImages.submit=Extract
#File to PDF
fileToPDF.title=File to PDF
fileToPDF.header=Convert any file to PDF
fileToPDF.credit=This service uses LibreOffice and Unoconv for file conversion.
fileToPDF.supportedFileTypes=Supported file types should include the below however for a full updated list of supported formats, please refer to the LibreOffice documentation
fileToPDF.submit=Convert to PDF
#Add image
@ -224,19 +266,8 @@ changeMetadata.selectText.5=Add Custom Metadata Entry
changeMetadata.submit=Change
xlsToPdf.title=Excel to PDF
xlsToPdf.header=Excel to PDF
xlsToPdf.selectText.1=Select XLS or XLSX Excel sheet to convert
xlsToPdf.convert=convert
fileToPDF.credit=This service uses LibreOffice and Unoconv for file conversion.
fileToPDF.supportedFileTypes=Supported file types should include the below however for a full updated list of supported formats, please refer to the LibreOffice documentation

View File

@ -19,6 +19,8 @@ goToPage=Aller
true=Vrai
false=Faux
unknown=Inconnu
save=Enregistrer
close=Fermer
#############
# HOME-PAGE #
@ -75,8 +77,52 @@ home.compressPdfs.desc=Compressez les PDF pour réduire leur taille de fichier.
home.changeMetadata.title=Modifier les métadonnées
home.changeMetadata.desc=Modifier/Supprimer/Ajouter des métadonnées d'un document PDF
home.xlsToPdf.title=Excel (Xls) en PDF
home.xlsToPdf.desc=Convertir un document Excel (xls, xlsx) en PDF.
home.fileToPDF.title=Convertir un fichier en PDF
home.fileToPDF.desc=Convertissez presque n\u2019importe quel fichier en PDF (DOCX, PNG, XLS, PPT, TXT et plus)
home.ocr.title=Exécuter OCR sur PDF
home.ocr.desc=Analyse et détecte le texte des images d\u2019un fichier PDF et le rajoute en tant que texte.
home.extractImages.title=Extraire les images
home.extractImages.desc=Extrait toutes les images d\u2019un PDF et les enregistre au format zip
navbar.settings=Paramètres
settings.title=Paramètres
settings.update=Mise à jour disponible
settings.appVersion=Version de l\u2019application :
settings.downloadOption.title=Choisissez l\u2019option de téléchargement (pour les téléchargements sans fichier unique) :
settings.downloadOption.1=Ouvrir dans la même fenêtre
settings.downloadOption.2=Ouvrir dans une nouvelle fenêtre
settings.downloadOption.3=Fichier téléchargé
settings.zip=Fichiers multi-téléchargements Zip
#OCR
ocr.title=OCR
ocr.header=OCR (reconnaissance optique de caractères)
ocr.selectText.1=Sélectionnez les langues à détecter dans le fichier PDF (celles répertoriées sont celles actuellement détectées) :
ocr.selectText.2=Produire un fichier texte contenant du texte OCR à côté du PDF OCR
ocr.help=Veuillez lire cette documentation pour savoir comment l\u2019utiliser pour d\u2019autres langues et/ou une utilisation non dans docker
ocr.credit=Ce service utilise OCRmyPDF et Tesseract pour l\u2019OCR.
ocr.submit=Traiter PDF avec OCR
extractImages.title=Extraire les images
extractImages.header=Extraire les images
extractImages.selectText=Sélectionner le format d\u2019image pour convertir les images extraites en
extractImages.submit=Extrait
#File au format PDF
fileToPDF.title=Fichier au PDF
fileToPDF.header=Convertir n\u2019importe quel fichier au format PDF
fileToPDF.credit=Ce service utilise LibreOffice et Unoconv pour la conversion de fichiers.
fileToPDF.supportedFileTypes=Les types de fichiers pris en charge doivent inclure les éléments ci-dessous, mais pour une liste complète et mise à jour des formats pris en charge, veuillez vous référer à la documentation de LibreOffice
fileToPDF.submit=Convertir en PDF
#Add image

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="43" height="43" fill="#007bff" class="bi bi-discord" viewBox="0 0 16 16">
<path d="M13.545 2.907a13.227 13.227 0 0 0-3.257-1.011.05.05 0 0 0-.052.025c-.141.25-.297.577-.406.833a12.19 12.19 0 0 0-3.658 0 8.258 8.258 0 0 0-.412-.833.051.051 0 0 0-.052-.025c-1.125.194-2.22.534-3.257 1.011a.041.041 0 0 0-.021.018C.356 6.024-.213 9.047.066 12.032c.001.014.01.028.021.037a13.276 13.276 0 0 0 3.995 2.02.05.05 0 0 0 .056-.019c.308-.42.582-.863.818-1.329a.05.05 0 0 0-.01-.059.051.051 0 0 0-.018-.011 8.875 8.875 0 0 1-1.248-.595.05.05 0 0 1-.02-.066.051.051 0 0 1 .015-.019c.084-.063.168-.129.248-.195a.05.05 0 0 1 .051-.007c2.619 1.196 5.454 1.196 8.041 0a.052.052 0 0 1 .053.007c.08.066.164.132.248.195a.051.051 0 0 1-.004.085 8.254 8.254 0 0 1-1.249.594.05.05 0 0 0-.03.03.052.052 0 0 0 .003.041c.24.465.515.909.817 1.329a.05.05 0 0 0 .056.019 13.235 13.235 0 0 0 4.001-2.02.049.049 0 0 0 .021-.037c.334-3.451-.559-6.449-2.366-9.106a.034.034 0 0 0-.02-.019Zm-8.198 7.307c-.789 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.45.73 1.438 1.613 0 .888-.637 1.612-1.438 1.612Zm5.316 0c-.788 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.451.73 1.438 1.613 0 .888-.631 1.612-1.438 1.612Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -2,4 +2,5 @@
<svg width="50px" height="50px" viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg">
<path stroke="#007bff" stroke-width="38" d="M 297.507 242.806 L 339.507 242.806 M 247.507 242.806 L 289.507 242.806 M 198.507 242.806 L 240.507 242.806 M 149.507 242.806 L 190.507 242.806 M 99.507 242.806 L 141.507 242.806 M 149.507 196.806 L 190.507 196.806 M 198.507 196.806 L 240.507 196.806 M 247.507 196.806 L 289.507 196.806 M 247.507 150.806 L 289.507 150.806"/>
<path fill="#007bff" d="M 473.507 244.806 C 473.507 244.806 455.507 227.806 418.507 233.806 C 414.507 204.806 383.507 187.806 383.507 187.806 C 383.507 187.806 354.507 222.806 375.507 261.806 C 369.507 264.806 359.507 268.806 344.507 268.806 L 69.507 268.806 C 64.507 287.806 64.507 413.806 202.507 413.806 C 301.507 413.806 375.507 367.806 410.507 283.806 C 462.507 287.806 473.507 244.806 473.507 244.806"/>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 919 B

After

Width:  |  Height:  |  Size: 921 B

File diff suppressed because one or more lines are too long

15821
src/main/resources/static/pdfjs/pdf.js vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -28,7 +28,6 @@
</div>
<button type="submit" class="btn btn-primary" th:text="#{addImage.submit}"></button>
</form>
<th:block th:insert="~{fragments/common :: filelist}"></th:block>
</div>
</div>
</div>

View File

@ -14,16 +14,28 @@
<div class="row justify-content-center">
<div class="col-md-6">
<h2 th:text="#{compress.header}"></h2>
<form action="#" th:action="@{compress-pdf}" th:object="${rotateForm}" method="post" enctype="multipart/form-data">
<p th:text="#{processTimeWarning}"></p>
<div th:replace="~{fragments/common :: fileSelector(name='fileInput', multiple=false)}"></div>
<div class="form-group">
<label for="imageCompressionLevel" th:text="#{compress.compressLevel}"></label>
<input type="number" class="form-control" id="imageCompressionLevel" name="imageCompressionLevel" step="1" value="1" min="1" max="100" required>
</div>
<button type="submit" class="btn btn-primary" th:text="#{compress.submit}"></button>
</form>
<th:block th:insert="~{fragments/common :: filelist}"></th:block>
<form action="#" th:action="@{/compress-pdf}" method="post" enctype="multipart/form-data">
<div th:replace="~{fragments/common :: fileSelector(name='fileInput', multiple=false)}"></div>
<div>
<label for="optimizeLevel" th:text="#{compress.selectText.1}"></label>
<select name="optimizeLevel" id="optimizeLevel">
<option value="0" th:text="#{compress.selectText.2}"></option>
<option value="1" selected th:text="#{compress.selectText.3}"></option>
<option value="2" th:text="#{compress.selectText.4}"></option>
<option value="3" th:text="#{compress.selectText.5}"></option>
</select>
</div>
<div>
<input type="checkbox" name="fastWebView" id="fastWebView">
<label for="fastWebView" th:text="#{compress.selectText.6}"></label>
</div>
<div>
<input type="checkbox" name="jbig2Lossy" id="jbig2Lossy">
<label for="jbig2Lossy" th:text="#{compress.selectText.7}"></label>
</div>
<button type="submit" th:text="#{compress.submit}"></button>
</form>
<p class="mt-3" th:text="#{compress.credit}"></p>
</div>
</div>

View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:lang-direction="#{language.direction}" xmlns:th="http://www.thymeleaf.org">
<th:block th:insert="~{fragments/common :: head(title=#{fileToPDF.title})}"></th:block>
<body>
<div id="page-container">
<div id="content-wrap">
<div th:insert="~{fragments/navbar.html :: navbar}"></div>
<br> <br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6">
<h2 th:text="#{fileToPDF.header}"></h2>
<p th:text="#{processTimeWarning}">
<form method="post" enctype="multipart/form-data" th:action="@{file-to-pdf}">
<div th:replace="~{fragments/common :: fileSelector(name='fileInput', multiple=false)}"></div>
<br>
<button type="submit" class="btn btn-primary" th:text="#{fileToPDF.submit}"></button>
</form>
<p class="mt-3" th:text="#{fileToPDF.credit}"></p>
<p class="mt-3" th:text="#{fileToPDF.supportedFileTypes}"></p>
<p th:utext="#{fileToPDF.fileTypesList}"></p>
<a href="https://help.libreoffice.org/latest/en-US/text/shared/guide/supported_formats.html">https://help.libreoffice.org/latest/en-US/text/shared/guide/supported_formats.html</a>
</div>
</div>
</div>
</div>
<div th:insert="~{fragments/footer.html :: footer}"></div>
</div>
</body>
</html>

View File

@ -23,7 +23,6 @@
<button type="submit" class="btn btn-primary" th:text="#{imageToPDF.submit}"></button>
</form>
<th:block th:insert="~{fragments/common :: filelist}"></th:block>
</div>
</div>
</div>

View File

@ -46,7 +46,6 @@
</div>
<button type="submit" class="btn btn-primary" th:text="#{pdfToImage.submit}"></button>
</form>
<th:block th:insert="~{fragments/common :: filelist}"></th:block>
</div>
</div>

View File

@ -1,35 +0,0 @@
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:lang-direction="#{language.direction}" xmlns:th="http://www.thymeleaf.org">
<th:block th:insert="~{fragments/common :: head(title=#{xlsToPdf.title})}"></th:block>
<body>
<div id="page-container">
<div id="content-wrap">
<div th:insert="~{fragments/navbar.html :: navbar}"></div>
<br> <br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6">
<h2 th:text="#{xlsToPdf.header}"></h2>
<form method="post" enctype="multipart/form-data" th:action="@{xlsx-to-pdf}">
<div class="custom-file">
<input type="file" class="custom-file-input" id="fileInput" name="fileInput" required>
<label class="custom-file-label" for="fileInput" th:text="#{xlsToPdf.selectText.1}"></label>
</div>
<br> <br>
<button type="submit" class="btn btn-primary" th:text="#{imageToPDF.submit}"></button>
</form>
<th:block th:insert="~{fragments/common :: filelist}"></th:block>
</div>
</div>
</div>
</div>
<div th:insert="~{fragments/footer.html :: footer}"></div>
</div>
</body>
</html>

View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:lang-direction="#{language.direction}" xmlns:th="http://www.thymeleaf.org">
<th:block th:insert="~{fragments/common :: head(title=#{extractImages.title})}"></th:block>
<body>
<div id="page-container">
<div id="content-wrap">
<div th:insert="~{fragments/navbar.html :: navbar}"></div>
<br> <br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6">
<h2 th:text="#{extractImages.header}"></h2>
<form id="multiPdfForm" th:action="@{extract-images}" method="post" enctype="multipart/form-data">
<div th:replace="~{fragments/common :: fileSelector(name='fileInput', multiple=false)}"></div>
<div class="form-group">
<label th:text="#{extractImages.selectText}"></label>
<select class="form-control" name="format">
<option value="png">PNG</option>
<option value="jpg">JPG</option>
<option value="gif">GIF</option>
</select>
</div>
<button type="submit" class="btn btn-primary" th:text="#{extractImages.submit}"></button>
</form>
</div>
</div>
</div>
</div>
<div th:insert="~{fragments/footer.html :: footer}"></div>
</div>
</body>
</html>

View File

@ -9,6 +9,9 @@
<!-- jQuery -->
<script src="js/jquery.min.js"></script>
<!-- jQuery -->
<script src="js/jszip.min.js"></script>
<!-- Bootstrap -->
<script src="js/popper.min.js"></script>
<script src="js/bootstrap.min.js"></script>
@ -16,122 +19,367 @@
<link rel="stylesheet" href="css/bootstrap-icons.css">
<!-- PDF.js -->
<script src="pdfjs/pdf.min.js"></script>
<link href="pdfjs/pdf_viewer.min.css" rel="stylesheet">
<script src="pdfjs/pdf.js"></script>
<!-- Custom -->
<link rel="stylesheet" href="css/general.css">
<link rel="stylesheet" th:href="@{css/dark-mode.css}" id="dark-mode-styles">
<script>
function toggleDarkMode() {
var checkbox = document.getElementById("toggle-dark-mode");
var darkModeStyles = document.getElementById("dark-mode-styles");
if (checkbox.checked) {
localStorage.setItem("dark-mode", "on");
darkModeStyles.disabled = false;
} else {
localStorage.setItem("dark-mode", "off");
darkModeStyles.disabled = true;
}
}
$(document).ready(function() {
var darkModeStyles = document.getElementById("dark-mode-styles");
var checkbox = document.getElementById("toggle-dark-mode");
if (localStorage.getItem("dark-mode") == "on") {
darkModeStyles.disabled = false;
checkbox.checked = true;
}
if (localStorage.getItem("dark-mode") == "off") {
darkModeStyles.disabled = true;
checkbox.checked = false;
}
function toggleDarkMode() {
var checkbox = document.getElementById("toggle-dark-mode");
var darkModeStyles = document.getElementById("dark-mode-styles");
if (checkbox.checked) {
localStorage.setItem("dark-mode", "on");
darkModeStyles.disabled = false;
} else {
localStorage.setItem("dark-mode", "off");
darkModeStyles.disabled = true;
}
}
$(document).ready(function() {
var darkModeStyles = document.getElementById("dark-mode-styles");
var checkbox = document.getElementById("toggle-dark-mode");
// Check if the user has already set a preference
if (localStorage.getItem("dark-mode") == "on") {
darkModeStyles.disabled = false;
checkbox.checked = true;
} else if (localStorage.getItem("dark-mode") == "off") {
darkModeStyles.disabled = true;
checkbox.checked = false;
} else {
// Check the OS's default dark mode setting
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
darkModeStyles.disabled = false;
checkbox.checked = true;
} else {
darkModeStyles.disabled = true;
checkbox.checked = false;
}
}
});
});
</script>
</head>
<th:block th:fragment="filelist">
<div id="fileList"></div>
<div id="fileList2"></div>
<script>
var input = document.getElementById("fileInput");
var output = document.getElementById("fileList");
input.addEventListener("change", function() {
var files = input.files;
var fileNames = "";
for (var i = 0; i < files.length; i++) {
fileNames += (i + 1) + ". " + files[i].name + "<br>";
}
output.innerHTML = fileNames;
});
</script>
<script>
var input2 = document.getElementById("fileInput2");
var output2 = document.getElementById("fileList2");
if (input2 != null && !input2) {
input2.addEventListener("change", function() {
var files = input2.files;
var fileNames = "";
for (var i = 0; i < files.length; i++) {
fileNames += (i + 1) + ". " + files[i].name + "<br>";
}
output2.innerHTML = fileNames;
});
}
</script>
<script>
if (dropContainer) {
dropContainer.ondragover = dropContainer.ondragenter = function(evt) {
evt.preventDefault();
};
dropContainer.ondrop = function(evt) {
if (fileInput) {
fileInput.files = evt.dataTransfer.files;
const dT = new DataTransfer();
dT.items.add(evt.dataTransfer.files[0]);
dT.items.add(evt.dataTransfer.files[3]);
fileInput.files = dT.files;
evt.preventDefault();
}
};
}
</script>
</th:block>
<th:block th:fragment="fileSelector(name, multiple)">
<div class="custom-file-chooser">
<div class="custom-file">
<input type="file" class="custom-file-input" th:name="${name}" th:id="${name}+'-input'" th:multiple="${multiple}">
<label class="custom-file-label" th:for="${name}+'-input'" th:text="#{pdfPrompt}"></label>
</div>
</div>
<div class="custom-file">
<input type="file" class="custom-file-input" th:name="${name}" th:id="${name}+'-input'" multiple>
<label class="custom-file-label" th:for="${name}+'-input'" th:text="#{pdfPrompt}"></label>
</div>
<div class="selected-files"></div>
</div>
<br>
<div id="progressBarContainer" style="display: none; position: relative;">
<div class="progress" style="height: 1rem;">
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated bg-success" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="width: 0%;">
<span class="sr-only">Loading...</span>
</div>
</div>
</div>
<script>
$('form').submit(function(event) {
console.log("start download code")
var files = $('#fileInput-input')[0].files;
var url = this.action;
console.log(url)
event.preventDefault(); // Prevent the default form handling behavior
/* Check if ${multiple} is false */
var multiple = [[${multiple}]] || false;
if (!multiple && files.length > 1) {
console.log("multi parallel download")
submitMultiPdfForm(event,url);
} else {
console.log("start single download")
// Get the selected download option from localStorage
const downloadOption = localStorage.getItem('downloadOption');
var formData = new FormData($('form')[0]);
// Send the request to the server using the fetch() API
fetch(url, {
method: 'POST',
body: formData
}).then(response => {
if (!response) {
throw new Error('Received null response for file ' + i);
}
console.log("load single download")
// Extract the filename from the Content-Disposition header, if present
let filename = null;
const contentDispositionHeader = response.headers.get('Content-Disposition');
console.log(contentDispositionHeader)
if (contentDispositionHeader && contentDispositionHeader.indexOf('attachment') !== -1) {
filename = contentDispositionHeader.split('filename=')[1].replace(/"/g, '');
} else {
// If the Content-Disposition header is not present or does not contain the filename, use a default filename
filename = 'download';
}
console.log("filename=" + filename)
const contentType = response.headers.get('Content-Type');
console.log("contentType=" + contentType)
// Check if the response is a PDF or an image
if (contentType.includes('pdf') || contentType.includes('image')) {
response.blob().then(blob => {
console.log("pdf/image")
// Perform the appropriate action based on the download option
if (downloadOption === 'sameWindow') {
console.log("same window")
// Open the file in the same window
window.location.href = URL.createObjectURL(blob);
} else if (downloadOption === 'newWindow') {
console.log("new window")
// Open the file in a new window
window.open(URL.createObjectURL(blob), '_blank');
} else {
console.log("else save")
// Download the file
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
}
});
} else if (contentType.includes('json')) {
// Handle the JSON response
response.json().then(data => {
// Format the error message
const errorMessage = JSON.stringify(data, null, 2);
// Display the error message in an alert
alert(`An error occurred: ${errorMessage}`);
});
} else {
response.blob().then(blob => {
console.log("else save 2 zip")
// For ZIP files or other file types, just download the file
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
});
}
})
.catch(error => {
console.log("error listener")
// Extract the error message and stack trace from the response
const errorMessage = error.message;
const stackTrace = error.stack;
// Create an error message to display to the user
const message = `${errorMessage}\n\n${stackTrace}`;
// Display the error message to the user
alert(message);
});
}
});
async function submitMultiPdfForm(event,url) {
// Get the selected PDF files
var files = $('#fileInput-input')[0].files;
// Get the existing form data
var formData = new FormData($('form')[0]);
formData.delete('fileInput');
// Show the progress bar
$('#progressBarContainer').show();
// Initialize the progress bar
var progressBar = $('#progressBar');
progressBar.css('width', '0%');
progressBar.attr('aria-valuenow', 0);
progressBar.attr('aria-valuemax', files.length);
// Check the flag in localStorage
const zipFiles = localStorage.getItem('zipParallelFiles') === 'true';
// Initialize JSZip instance if needed
let jszip = null;
if (zipFiles) {
jszip = new JSZip();
}
// Submit each PDF file in parallel
var promises = [];
for (var i = 0; i < files.length; i++) {
var promise = new Promise(function(resolve, reject) {
var fileFormData = new FormData();
fileFormData.append('fileInput', files[i]);
for (var pair of formData.entries()) {
fileFormData.append(pair[0], pair[1]);
}
console.log(fileFormData);
fetch(url, {
method: 'POST',
body: fileFormData
}).then(function(response) {
if (!response) {
throw new Error('Received null response for file ' + i);
}
console.log('Received response for file ' + i + ': ' + response);
var contentDisposition = response.headers.get('content-disposition');
var fileName = contentDisposition.split('filename=')[1].replace(/"/g, '');
response.blob().then(function (blob) {
if (zipFiles) {
// Add the file to the ZIP archive
jszip.file(fileName, blob);
resolve();
} else {
// Download the file directly
var url = window.URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
a.remove();
resolve();
}
});
}).catch(function(error) {
console.error('Error submitting request for file ' + i + ': ' + error);
// Set default values or fallbacks for error properties
var status = error && error.status || 500;
var statusText = error && error.statusText || 'Internal Server Error';
var message = error && error.message || 'An error occurred while processing your request.';
// Reject the Promise to signal that the request has failed
reject();
// Redirect to error page with Spring Boot error parameters
var url = '/error?status=' + status + '&error=' + encodeURIComponent(statusText) + '&message=' + encodeURIComponent(message);
window.location.href = url;
});
});
// Update the progress bar as each request finishes
promise.then(function() {
var progress = ((progressBar.attr('aria-valuenow') / files.length) * 100) + (100 / files.length);
progressBar.css('width', progress + '%');
progressBar.attr('aria-valuenow', parseInt(progressBar.attr('aria-valuenow')) + 1);
});
promises.push(promise);
}
// Wait for all requests to finish
try {
await Promise.all(promises);
} catch (error) {
console.error('Error while uploading files: ' + error);
}
// Update the progress bar
progressBar.css('width', '100%');
progressBar.attr('aria-valuenow', files.length);
// After all requests are finished, download the ZIP file if needed
if (zipFiles) {
jszip.generateAsync({ type: "blob" }).then(function (content) {
var url = window.URL.createObjectURL(content);
var a = document.createElement('a');
a.href = url;
a.download = "files.zip";
document.body.appendChild(a);
a.click();
a.remove();
});
}
}
</script>
<script th:inline="javascript">
$([[${"#"+name+"-input"}]]).on("change", function() {
const files = $(this).get(0).files;
const fileNames = Array.from(files).map(f => f.name).join(", ");
if (fileNames) {
$(this).siblings(".custom-file-label").addClass("selected").html(fileNames);
} else {
$(this).siblings(".custom-file-label").addClass("selected").html([[#{pdfPrompt}]]);
}
});
document.addEventListener('DOMContentLoaded', function () {
const fileInput = document.getElementById('fileInput-input');
// Prevent default behavior for drag events
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
fileInput.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
// Add drop event listener
fileInput.addEventListener('drop', handleDrop, false);
function handleDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
fileInput.files = files;
handleFileInputChange(fileInput)
}
});
$([[${"#"+name+"-input"}]]).on("change", function() {
handleFileInputChange(this);
});
function handleFileInputChange(inputElement) {
const files = $(inputElement).get(0).files;
const fileNames = Array.from(files).map(f => f.name);
const selectedFilesContainer = $(inputElement).siblings(".selected-files");
selectedFilesContainer.empty();
fileNames.forEach(fileName => {
selectedFilesContainer.append("<div>" + fileName + "</div>");
});
if (fileNames.length === 1) {
$(inputElement).siblings(".custom-file-label").addClass("selected").html(fileNames[0]);
} else if (fileNames.length > 1) {
$(inputElement).siblings(".custom-file-label").addClass("selected").html(fileNames.length + " files selected");
} else {
$(inputElement).siblings(".custom-file-label").addClass("selected").html([[#{pdfPrompt}]]);
}
}
</script>
<style>
.custom-file-label {
padding-right: 90px;
}
padding-right: 90px;
}
.selected-files {
margin-top: 10px;
max-height: 150px;
overflow-y: auto;
white-space: pre-wrap;
}
</style>
</th:block>

View File

@ -1,4 +1,5 @@
<th:block th:fragment="errorBanner">
<style>
#github-button,
#discord-button {
@ -17,7 +18,7 @@
background-color: #005b7f;
}
</style>
<br th:if="${message}">
<br th:if="${message}">
<div id="errorContainer" th:if="${message}" class="alert alert-danger alert-dismissible fade show" role="alert">
<h4 class="alert-heading" th:text="'Error: ' + ${status} + ' ' + ${error}"></h4>
<p th:text="${message} + ' for path: ' + ${path}"></p>
@ -36,39 +37,53 @@
<a href="https://discord.gg/Cn8pWhQRxZ" id="discord-button" target="_blank">Join our Discord server</a>
</div>
</div>
<script>
function toggletrace() {
var traceDiv = document.getElementById("trace");
if (traceDiv.style.maxHeight === "0px") {
traceDiv.style.maxHeight = "500px";
} else {
traceDiv.style.maxHeight = "0px";
<script>
var traceVisible = false;
function toggletrace() {
var traceDiv = document.getElementById("trace");
if (!traceVisible) {
traceDiv.style.maxHeight = "500px";
traceVisible = true;
} else {
traceDiv.style.maxHeight = "0px";
traceVisible = false;
}
adjustContainerHeight();
}
adjustContainerHeight();
}
function copytrace() {
var traceContent = document.getElementById("traceContent");
var range = document.createRange();
range.selectNode(traceContent);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
document.execCommand("copy");
window.getSelection().removeAllRanges();
}
function copytrace() {
var flip = false
if(!traceVisible) {
toggletrace()
flip = true
}
var traceContent = document.getElementById("traceContent");
var range = document.createRange();
range.selectNode(traceContent);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
document.execCommand("copy");
window.getSelection().removeAllRanges();
if(flip){
toggletrace()
}
}
function dismissError() {
var errorContainer = document.getElementById("errorContainer");
errorContainer.style.display = "none";
errorContainer.style.height ="0";
}
function dismissError() {
var errorContainer = document.getElementById("errorContainer");
errorContainer.style.display = "none";
errorContainer.style.height ="0";
}
function adjustContainerHeight() {
var errorContainer = document.getElementById("errorContainer");
var traceDiv = document.getElementById("trace");
errorContainer.style.height = errorContainer.scrollHeight - traceDiv.scrollHeight + traceDiv.offsetHeight + "px";
}
function adjustContainerHeight() {
var errorContainer = document.getElementById("errorContainer");
var traceDiv = document.getElementById("trace");
if (traceVisible) {
errorContainer.style.height = errorContainer.scrollHeight - traceDiv.scrollHeight + traceDiv.offsetHeight + "px";
} else {
errorContainer.style.height = "auto";
}
}
</script>
</th:block>
</th:block>

View File

@ -1,6 +1,7 @@
<div th:fragment="footer">
<footer id="footer" class="text-center py-3">
<a href="https://github.com/Frooodle/Stirling-PDF" target="_blank" class="mx-1"><img src="images/github.svg"></img></a>
<a href="https://hub.docker.com/r/frooodle/s-pdf" target="_blank" class="mx-1"><img src="images/docker.svg"></img></a>
<a href="https://hub.docker.com/r/frooodle/s-pdf" target="_blank" class="mx-1"><img src="images/docker.svg"></img></a>
<a href="https://discord.gg/Cn8pWhQRxZ" target="_blank" class="mx-1"><img src="images/discord.svg"></img></a>
</footer>
</div>

View File

@ -1,133 +1,271 @@
<div th:fragment="navbar">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container">
<div th:fragment="navbar" class="mx-auto">
<script>
document
.addEventListener(
'DOMContentLoaded',
function() {
// Get the dropdown items
var dropdownItems = document
.querySelectorAll('.lang_dropdown-item');
<a class="navbar-brand" href="#" th:href="@{/}">Stirling PDF</a>
// Loop through the dropdown items
for (var i = 0; i < dropdownItems.length; i++) {
dropdownItems[i].classList
.remove('active');
if (dropdownItems[i].dataset.languageCode === localStorage
.getItem('languageCode')) {
dropdownItems[i].classList
.add('active');
}
<button class="navbar-toggler" type="button" data-toggle="collapse"
data-target="#navbarNav" aria-controls="navbarNav"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
// Add a click event listener to each dropdown item
dropdownItems[i]
.addEventListener(
'click',
function(event) {
<div class="collapse navbar-collapse" id="navbarNav">
// Prevent the default link behavior
event
.preventDefault();
<ul class="navbar-nav">
// Get the language code
var languageCode = this.dataset.languageCode;
<li class="nav-item">
<a class="nav-link" href="#" th:href="@{merge-pdfs}" th:classappend="${currentPage}=='merge-pdfs' ? 'active' : ''" th:text="#{home.merge.title}"></a>
</li>
// Save the language code to local storage
localStorage
.setItem(
'languageCode',
languageCode);
<li class="nav-item">
<a class="nav-link" href="#" th:href="@{split-pdfs}" th:classappend="${currentPage}=='split-pdfs' ? 'active' : ''" th:text="#{home.split.title}"></a>
</li>
// Get the current URL
var currentUrl = window.location.href;
<li class="nav-item">
<a class="nav-link" href="#" th:href="@{pdf-organizer}" th:classappend="${currentPage}=='pdf-organizer' ? 'active' : ''" th:text="#{home.pdfOrganiser.title}"></a>
</li>
// Check if the URL already contains a "?lang" query parameter
if (currentUrl
.indexOf('?lang=') === -1) {
// Update the URL with the "?lang" query parameter
window.location.href = currentUrl
+ '?lang='
+ languageCode;
} else {
// Replace the "?lang" query parameter with the new value
window.location.href = currentUrl
.replace(
/\?lang=\w{2,}/,
'?lang='
+ languageCode);
}
});
}
});
</script>
<li class="nav-item">
<a class="nav-link" href="#" th:href="@{rotate-pdf}" th:classappend="${currentPage}=='rotate-pdf' ? 'active' : ''" th:text="#{home.rotate.title}"></a>
</li>
<script th:inline="javascript">
function compareVersions(version1, version2) {
const v1 = version1.split('.');
const v2 = version2.split('.');
for (let i = 0; i < v1.length || i < v2.length; i++) {
const n1 = parseInt(v1[i]) || 0;
const n2 = parseInt(v2[i]) || 0;
if (n1 > n2) {
return 1;
} else if (n1 < n2) {
return -1;
}
}
return 0;
}
async function getLatestReleaseVersion() {
const url = "https://api.github.com/repos/Frooodle/Stirling-PDF/releases/latest";
const response = await fetch(url);
const data = await response.json();
return data.tag_name.substring(1);
}
<li class="nav-item dropdown" th:classappend="${currentPage}=='pdf-to-img' OR ${currentPage}=='img-to-pdf' OR ${currentPage}=='xlsx-to-pdf' ? 'active' : ''">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" th:text="#{navbar.convert}"></a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="#" th:href="@{pdf-to-img}" th:classappend="${currentPage}=='pdf-to-img' ? 'active' : ''" th:text="#{home.pdfToImage.title}"></a>
<a class="dropdown-item" href="#" th:href="@{img-to-pdf}" th:classappend="${currentPage}=='img-to-pdf' ? 'active' : ''" th:text="#{home.imageToPdf.title}"></a>
<a class="dropdown-item" href="#" th:href="@{xlsx-to-pdf}" th:classappend="${currentPage}=='xlsx-to-pdf' ? 'active' : ''" th:text="#{home.xlsToPdf.title}"></a>
</div>
</li>
const currentVersion = /*[[${@appVersion}]]*/ ''; // Replace with your current version number
<li class="nav-item dropdown" th:classappend="${currentPage}=='add-password' OR ${currentPage}=='remove-password' OR ${currentPage}=='change-permissions' OR ${currentPage}=='add-watermark' OR ${currentPage}=='remove-watermark' ? 'active' : ''">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" th:text="#{navbar.security}"></a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="#" th:href="@{add-password}" th:classappend="${currentPage}=='add-password' ? 'active' : ''" th:text="#{home.addPassword.title}"></a>
<a class="dropdown-item" href="#" th:href="@{remove-password}" th:classappend="${currentPage}=='remove-password' ? 'active' : ''" th:text="#{home.removePassword.title}"></a>
<a class="dropdown-item" href="#" th:href="@{change-permissions}" th:classappend="${currentPage}=='change-permissions' ? 'active' : ''" th:text="#{home.permissions.title}"></a>
<a class="dropdown-item" href="#" th:href="@{add-watermark}" th:classappend="${currentPage}=='add-watermark' ? 'active' : ''" th:text="#{home.watermark.title}"></a>
<a class="dropdown-item" href="#" th:href="@{change-metadata}" th:classappend="${currentPage}=='change-metadata' ? 'active' : ''" th:text="#{home.changeMetadata.title}"></a>
</div>
</li>
async function checkForUpdate() {
const latestVersion = await getLatestReleaseVersion();
console.log("latestVersion=" + latestVersion)
console.log("currentVersion=" + currentVersion)
console.log("compareVersions(latestVersion, currentVersion) > 0)=" + compareVersions(latestVersion, currentVersion))
if (latestVersion != null && latestVersion != "" && compareVersions(latestVersion, currentVersion) > 0) {
document.getElementById("update-btn").style.visibility = "visible";
console.log("visible")
} else {
document.getElementById("update-btn").style.visibility = "hidden";
console.log("hidden")
}
}
<li class="nav-item dropdown" th:classappend="${currentPage}=='remove-pages' OR ${currentPage}=='add-image' OR ${currentPage}=='compress-pdf' ? 'active' : ''">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" th:text="#{navbar.other}"></a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="#" th:href="@{add-image}" th:classappend="${currentPage}=='add-image' ? 'active' : ''" th:text="#{home.addImage.title}"></a>
<a class="dropdown-item" href="#" th:href="@{compress-pdf}" th:classappend="${currentPage}=='compress-pdf' ? 'active' : ''" th:text="#{home.compressPdfs.title}"></a>
<a class="dropdown-item" href="#" th:href="@{remove-pages}" th:classappend="${currentPage}=='remove-pages' ? 'active' : ''" th:text="#{home.removePages.title}"></a>
</div>
</li>
checkForUpdate();
</script>
<li class="nav-item">
<a class="nav-link" href="#" th:href="@{add-image}" th:classappend="${currentPage}=='add-image' ? 'active' : ''"></a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" th:href="@{compress-pdf}" th:classappend="${currentPage}=='compress-pdf' ? 'active' : ''"></a>
</li>
<input type="checkbox" id="toggle-dark-mode" checked="true" th:onclick="javascript:toggleDarkMode()">
<a class="nav-link" href="#" for="toggle-dark-mode" th:text="#{navbar.darkmode}"></a>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container ">
<a class="navbar-brand" href="#" th:href="@{/}">Stirling PDF</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav mr-auto flex-nowrap">
<li class="nav-item"><a class="nav-link" href="#" th:href="@{merge-pdfs}" th:classappend="${currentPage}=='merge-pdfs' ? 'active' : ''" th:text="#{home.merge.title}"></a></li>
<li class="nav-item"><a class="nav-link" href="#" th:href="@{split-pdfs}" th:classappend="${currentPage}=='split-pdfs' ? 'active' : ''" th:text="#{home.split.title}"></a></li>
<li class="nav-item"><a class="nav-link" href="#" th:href="@{pdf-organizer}" th:classappend="${currentPage}=='pdf-organizer' ? 'active' : ''" th:text="#{home.pdfOrganiser.title}"></a></li>
<li class="nav-item"><a class="nav-link" href="#" th:href="@{rotate-pdf}" th:classappend="${currentPage}=='rotate-pdf' ? 'active' : ''" th:text="#{home.rotate.title}"></a></li>
<li class="nav-item dropdown" th:classappend="${currentPage}=='pdf-to-img' OR ${currentPage}=='img-to-pdf' OR ${currentPage}=='file-to-pdf' OR ${currentPage}=='xlsx-to-pdf' ? 'active' : ''"><a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false" th:text="#{navbar.convert}"></a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="#" th:href="@{pdf-to-img}" th:classappend="${currentPage}=='pdf-to-img' ? 'active' : ''" th:text="#{home.pdfToImage.title}"></a>
<a class="dropdown-item" href="#" th:href="@{img-to-pdf}" th:classappend="${currentPage}=='img-to-pdf' ? 'active' : ''" th:text="#{home.imageToPdf.title}"></a>
<a class="dropdown-item" href="#" th:href="@{file-to-pdf}" th:classappend="${currentPage}=='file-to-pdf' ? 'active' : ''" th:text="#{home.fileToPDF.title}"></a>
</div>
</li>
<li class="nav-item dropdown" th:classappend="${currentPage}=='add-password' OR ${currentPage}=='remove-password' OR ${currentPage}=='change-permissions' OR ${currentPage}=='add-watermark' OR ${currentPage}=='remove-watermark' ? 'active' : ''">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" th:text="#{navbar.security}"></a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="#" th:href="@{add-password}" th:classappend="${currentPage}=='add-password' ? 'active' : ''" th:text="#{home.addPassword.title}"></a>
<a class="dropdown-item" href="#" th:href="@{remove-password}" th:classappend="${currentPage}=='remove-password' ? 'active' : ''" th:text="#{home.removePassword.title}"></a>
<a class="dropdown-item" href="#" th:href="@{change-permissions}" th:classappend="${currentPage}=='change-permissions' ? 'active' : ''" th:text="#{home.permissions.title}"></a>
<a class="dropdown-item" href="#" th:href="@{add-watermark}" th:classappend="${currentPage}=='add-watermark' ? 'active' : ''" th:text="#{home.watermark.title}"></a>
<a class="dropdown-item" href="#" th:href="@{change-metadata}" th:classappend="${currentPage}=='change-metadata' ? 'active' : ''" th:text="#{home.changeMetadata.title}"></a>
</div>
</li>
<li class="nav-item dropdown" th:classappend="${currentPage}=='remove-pages' OR ${currentPage}=='add-image' OR ${currentPage}=='ocr-pdf' OR ${currentPage}=='extract-images' OR ${currentPage}=='compress-pdf' ? 'active' : ''"><a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false" th:text="#{navbar.other}"></a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="#" th:href="@{ocr-pdf}" th:classappend="${currentPage}=='ocr-pdf' ? 'active' : ''" th:text="#{home.ocr.title}"></a>
<a class="dropdown-item" href="#" th:href="@{add-image}" th:classappend="${currentPage}=='add-image' ? 'active' : ''" th:text="#{home.addImage.title}"></a>
<a class="dropdown-item" href="#" th:href="@{compress-pdf}" th:classappend="${currentPage}=='compress-pdf' ? 'active' : ''" th:text="#{home.compressPdfs.title}"></a>
<a class="dropdown-item" href="#" th:href="@{remove-pages}" th:classappend="${currentPage}=='remove-pages' ? 'active' : ''" th:text="#{home.removePages.title}"></a>
<a class="dropdown-item" href="#" th:href="@{extract-images}" th:classappend="${currentPage}=='extract-images' ? 'active' : ''" th:text="#{home.extractImages.title}"></a>
</div></li>
<input type="checkbox" id="toggle-dark-mode" checked="true" th:onclick="javascript:toggleDarkMode()">
<a class="nav-link" href="#" for="toggle-dark-mode" th:text="#{navbar.darkmode}" ></a>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="languageDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="bi bi-globe2"></i>
</a>
<div class="dropdown-menu" aria-labelledby="languageDropdown">
<a class="dropdown-item lang_dropdown-item" href="" data-language-code="en_US">English (US)</a>
<a class="dropdown-item lang_dropdown-item" href="" data-language-code="en_GB">English (UK)</a>
<a class="dropdown-item lang_dropdown-item" href="" data-language-code="ar_AR">العربية</a>
<a class="dropdown-item lang_dropdown-item" href="" data-language-code="de_DE">Deutsch</a>
<a class="dropdown-item lang_dropdown-item" href="" data-language-code="fr_FR">Français</a>
</div>
</li>
</ul>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Get the dropdown items
var dropdownItems = document.querySelectorAll('.lang_dropdown-item');
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="languageDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-globe2" viewBox="0 0 20 20">
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855-.143.268-.276.56-.395.872.705.157 1.472.257 2.282.287V1.077zM4.249 3.539c.142-.384.304-.744.481-1.078a6.7 6.7 0 0 1 .597-.933A7.01 7.01 0 0 0 3.051 3.05c.362.184.763.349 1.198.49zM3.509 7.5c.036-1.07.188-2.087.436-3.008a9.124 9.124 0 0 1-1.565-.667A6.964 6.964 0 0 0 1.018 7.5h2.49zm1.4-2.741a12.344 12.344 0 0 0-.4 2.741H7.5V5.091c-.91-.03-1.783-.145-2.591-.332zM8.5 5.09V7.5h2.99a12.342 12.342 0 0 0-.399-2.741c-.808.187-1.681.301-2.591.332zM4.51 8.5c.035.987.176 1.914.399 2.741A13.612 13.612 0 0 1 7.5 10.91V8.5H4.51zm3.99 0v2.409c.91.03 1.783.145 2.591.332.223-.827.364-1.754.4-2.741H8.5zm-3.282 3.696c.12.312.252.604.395.872.552 1.035 1.218 1.65 1.887 1.855V11.91c-.81.03-1.577.13-2.282.287zm.11 2.276a6.696 6.696 0 0 1-.598-.933 8.853 8.853 0 0 1-.481-1.079 8.38 8.38 0 0 0-1.198.49 7.01 7.01 0 0 0 2.276 1.522zm-1.383-2.964A13.36 13.36 0 0 1 3.508 8.5h-2.49a6.963 6.963 0 0 0 1.362 3.675c.47-.258.995-.482 1.565-.667zm6.728 2.964a7.009 7.009 0 0 0 2.275-1.521 8.376 8.376 0 0 0-1.197-.49 8.853 8.853 0 0 1-.481 1.078 6.688 6.688 0 0 1-.597.933zM8.5 11.909v3.014c.67-.204 1.335-.82 1.887-1.855.143-.268.276-.56.395-.872A12.63 12.63 0 0 0 8.5 11.91zm3.555-.401c.57.185 1.095.409 1.565.667A6.963 6.963 0 0 0 14.982 8.5h-2.49a13.36 13.36 0 0 1-.437 3.008zM14.982 7.5a6.963 6.963 0 0 0-1.362-3.675c-.47.258-.995.482-1.565.667.248.92.4 1.938.437 3.008h2.49zM11.27 2.461c.177.334.339.694.482 1.078a8.368 8.368 0 0 0 1.196-.49 7.01 7.01 0 0 0-2.275-1.52c.218.283.418.597.597.932zm-.488 1.343a7.765 7.765 0 0 0-.395-.872C9.835 1.897 9.17 1.282 8.5 1.077V4.09c.81-.03 1.577-.13 2.282-.287z"/>
</svg>
</a>
<div class="dropdown-menu" aria-labelledby="languageDropdown">
<a class="dropdown-item lang_dropdown-item" href="" data-language-code="en_US">English (US)</a>
<a class="dropdown-item lang_dropdown-item" href="" data-language-code="en_GB">English (UK)</a>
<a class="dropdown-item lang_dropdown-item" href="" data-language-code="ar_AR">العربية</a>
<a class="dropdown-item lang_dropdown-item" href="" data-language-code="de_DE">Deutsch</a>
<a class="dropdown-item lang_dropdown-item" href="" data-language-code="fr_FR">Français</a>
</div>
</li>
<li class="nav-item">
<!-- Settings Button -->
<button type="button" class="btn btn-secondary float-right" data-toggle="modal" data-target="#settingsModal">
<span class="glyphicon glyphicon-cog"></span><span th:text="#{navbar.settings}"></span>
</button>
</li>
</ul>
// Loop through the dropdown items
for (var i = 0; i < dropdownItems.length; i++) {
dropdownItems[i].classList.remove('active');
if (dropdownItems[i].dataset.languageCode === localStorage.getItem('languageCode')) {
dropdownItems[i].classList.add('active');
}
</div>
// Add a click event listener to each dropdown item
dropdownItems[i].addEventListener('click', function(event) {
</div>
</nav>
<div th:insert="~{fragments/errorBanner.html :: errorBanner}"></div>
// Prevent the default link behavior
event.preventDefault();
<div class="modal fade" id="settingsModal" tabindex="-1" role="dialog" aria-labelledby="settingsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content dark-card">
<div class="modal-header">
<h5 class="modal-title" id="settingsModalLabel" th:text="#{settings.title}"></h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<p class="mb-0" th:text="#{settings.appVersion} + ' ' + ${@appVersion}"></p>
<a href="https://github.com/Frooodle/Stirling-PDF/releases" target="_blank">
<button type="button" class="btn btn-sm btn-outline-primary" id="update-btn" th:text="#{settings.update}"></button>
</a>
// Get the language code
var languageCode = this.dataset.languageCode;
</div>
<div class="form-group">
<label for="downloadOption" th:text="#{settings.downloadOption.title}"></label>
<select class="form-control" id="downloadOption">
<option value="sameWindow" th:text="#{settings.downloadOption.1}"></option>
<option value="newWindow" th:text="#{settings.downloadOption.2}"></option>
<option value="downloadFile" th:text="#{settings.downloadOption.3}"></option>
</select>
</div>
<div class="form-group">
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="zipParallelFiles">
<label class="custom-control-label" for="zipParallelFiles" th:text="#{settings.zip}"></label>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal" th:text="#{close}"></button>
</div>
</div>
</div>
</div>
// Save the language code to local storage
localStorage.setItem('languageCode', languageCode);
// Get the current URL
var currentUrl = window.location.href;
<script>
// Get the download option from local storage, or set it to 'sameWindow' if it doesn't exist
var downloadOption = localStorage.getItem('downloadOption')
|| 'sameWindow';
// Check if the URL already contains a "?lang" query parameter
if (currentUrl.indexOf('?lang=') === -1) {
// Update the URL with the "?lang" query parameter
window.location.href = currentUrl + '?lang=' + languageCode;
} else {
// Replace the "?lang" query parameter with the new value
window.location.href = currentUrl.replace(/\?lang=\w{2,}/, '?lang=' + languageCode);
}
// Set the selected option in the dropdown
document.getElementById('downloadOption').value = downloadOption;
// Save the selected option to local storage when the dropdown value changes
document.getElementById('downloadOption').addEventListener(
'change',
function() {
downloadOption = this.value;
localStorage.setItem('downloadOption',
downloadOption);
});
}
// Get the zipParallelFiles flag from local storage, or set it to false if it doesn't exist
var zipParallelFiles = localStorage.getItem('zipParallelFiles') === 'true';
// Set the checked state of the checkbox
document.getElementById('zipParallelFiles').checked = zipParallelFiles;
// Save the checked state of the checkbox to local storage when it changes
document.getElementById('zipParallelFiles').addEventListener('change', function () {
zipParallelFiles = this.checked;
localStorage.setItem('zipParallelFiles', zipParallelFiles);
});
</script>
</div>
</div>
</nav>
<div th:insert="~{fragments/errorBanner.html :: errorBanner}"></div>
</div>

View File

@ -50,8 +50,8 @@
<div th:replace="~{fragments/card :: card(cardTitle=#{home.addImage.title}, cardText=#{home.addImage.desc}, cardLink='add-image')}"></div>
<div th:replace="~{fragments/card :: card(cardTitle=#{home.watermark.title}, cardText=#{home.watermark.desc}, cardLink='add-watermark')}"></div>
<div th:replace="~{fragments/card :: card(cardTitle=#{home.xlsToPdf.title}, cardText=#{home.xlsToPdf.desc}, cardLink='xlsx-to-pdf')}"></div>
<div th:replace="~{fragments/card :: card(cardTitle=#{home.fileToPDF.title}, cardText=#{home.fileToPDF.desc}, cardLink='file-to-pdf')}"></div>
<div th:replace="~{fragments/card :: card(cardTitle=#{home.removePages.title}, cardText=#{home.removePages.desc}, cardLink='remove-pages')}"></div>
<div th:replace="~{fragments/card :: card(cardTitle=#{home.addPassword.title}, cardText=#{home.addPassword.desc}, cardLink='add-password')}"></div>
<div th:replace="~{fragments/card :: card(cardTitle=#{home.removePassword.title}, cardText=#{home.removePassword.desc}, cardLink='remove-password')}"></div>
@ -60,6 +60,10 @@
<div th:replace="~{fragments/card :: card(cardTitle=#{home.changeMetadata.title}, cardText=#{home.changeMetadata.desc}, cardLink='change-metadata')}"></div>
<div th:replace="~{fragments/card :: card(cardTitle=#{home.permissions.title}, cardText=#{home.permissions.desc}, cardLink='change-permissions')}"></div>
<div th:replace="~{fragments/card :: card(cardTitle=#{home.ocr.title}, cardText=#{home.ocr.desc}, cardLink='ocr-pdf')}"></div>
<div th:replace="~{fragments/card :: card(cardTitle=#{home.extractImages.title}, cardText=#{home.extractImages.desc}, cardLink='extract-images')}"></div>
</div>
</div>

View File

@ -28,94 +28,103 @@
<button type="submit" class="btn btn-primary" th:text="#{merge.submit}"></button>
</div>
</form>
<style>
.list-group-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.filename {
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 10px;
}
.arrows {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
}
.move-up span,
.move-down span {
font-weight: bold;
font-size: 1.2em;
}
</style>
<script>
document
.getElementById(
"fileInput")
.addEventListener(
"change",
function() {
var files = this.files;
var list = document
.getElementById("selectedFiles");
list.innerHTML = "";
for (var i = 0; i < files.length; i++) {
var item = document
.createElement("li");
item.className = "list-group-item d-flex justify-content-between align-items-center";
item.textContent = files[i].name;
item.innerHTML += '<div><button class="btn btn-secondary move-up"><i class="fas fa-arrow-up"></i></button> <button class="btn btn-secondary move-down"><i class="fas fa-arrow-down"></i></button></div>';
list
.appendChild(item);
}
var moveUpButtons = document
.querySelectorAll(".move-up");
for (var i = 0; i < moveUpButtons.length; i++) {
moveUpButtons[i]
.addEventListener(
"click",
function(
event) {
event
.preventDefault();
var parent = this.parentNode.parentNode;
var grandParent = parent.parentNode;
if (parent.previousElementSibling) {
grandParent
.insertBefore(
parent,
parent.previousElementSibling);
updateFiles();
}
});
}
var moveDownButtons = document
.querySelectorAll(".move-down");
for (var i = 0; i < moveDownButtons.length; i++) {
moveDownButtons[i]
.addEventListener(
"click",
function(
event) {
event
.preventDefault();
var parent = this.parentNode.parentNode;
var grandParent = parent.parentNode;
if (parent.nextElementSibling) {
grandParent
.insertBefore(
parent.nextElementSibling,
parent);
updateFiles();
}
});
}
document.getElementById("fileInput").addEventListener("change", function() {
var files = this.files;
var list = document.getElementById("selectedFiles");
list.innerHTML = "";
for (var i = 0; i < files.length; i++) {
var item = document.createElement("li");
item.className = "list-group-item";
item.innerHTML = `
<div class="d-flex justify-content-between align-items-center w-100">
<div class="filename">${files[i].name}</div>
<div class="arrows d-flex">
<button class="btn btn-secondary move-up"><span>&uarr;</span></button>
<button class="btn btn-secondary move-down"><span>&darr;</span></button>
</div>
</div>
`;
list.appendChild(item);
}
function updateFiles() {
var dataTransfer = new DataTransfer();
var liElements = document
.querySelectorAll("#selectedFiles li");
var moveUpButtons = document.querySelectorAll(".move-up");
for (var i = 0; i < moveUpButtons.length; i++) {
moveUpButtons[i].addEventListener("click", function(event) {
event.preventDefault();
var parent = this.closest(".list-group-item");
var grandParent = parent.parentNode;
if (parent.previousElementSibling) {
grandParent.insertBefore(parent, parent.previousElementSibling);
updateFiles();
}
});
}
var moveDownButtons = document.querySelectorAll(".move-down");
for (var i = 0; i < moveDownButtons.length; i++) {
moveDownButtons[i].addEventListener("click", function(event) {
event.preventDefault();
var parent = this.closest(".list-group-item");
var grandParent = parent.parentNode;
if (parent.nextElementSibling) {
grandParent.insertBefore(parent.nextElementSibling, parent);
updateFiles();
}
});
}
function updateFiles() {
var dataTransfer = new DataTransfer();
var liElements = document.querySelectorAll("#selectedFiles li");
for (var i = 0; i < liElements.length; i++) {
var fileNameFromList = liElements[i].querySelector(".filename").innerText;
var fileFromFiles;
for (var j = 0; j < files.length; j++) {
var file = files[j];
if (file.name === fileNameFromList) {
dataTransfer.items.add(file);
break;
}
}
}
document.getElementById("fileInput").files = dataTransfer.files;
}
});
for (var i = 0; i < liElements.length; i++) {
var fileNameFromList = liElements[i].innerText
.replace(
"\nMove Up Move Down",
"");
var fileFromFiles
for (var j = 0; j < files.length; j++) {
var file = files[j];
if (file.name === fileNameFromList) {
dataTransfer.items
.add(file);
break;
}
}
}
document
.getElementById("fileInput").files = dataTransfer.files;
}
});
</script>
</div>
</div>

View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:lang-direction="#{language.direction}" xmlns:th="http://www.thymeleaf.org">
<th:block th:insert="~{fragments/common :: head(title=#{ocr.title})}"></th:block>
<body>
<div id="page-container">
<div id="content-wrap">
<div th:insert="~{fragments/navbar.html :: navbar}"></div>
<br> <br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6">
<h2 th:text="#{ocr.header}"></h2>
<form action="#" th:action="@{/ocr-pdf}" method="post" enctype="multipart/form-data" class="mb-3">
<div th:replace="~{fragments/common :: fileSelector(name='fileInput', multiple=false)}"></div>
<div class="form-group">
<label for="languages" class="form-label" th:text="#{ocr.selectText.1}"></label>
<div id="languages">
<div th:each="language: ${languages}">
<input type="checkbox" class="form-check-input" th:name="languages" th:value="${language}" th:id="${'language-' + language}" />
<label class="form-check-label" th:for="${'language-' + language}" th:text="${language}"></label>
</div>
</div>
</div>
<!-- <div class="form-group">
<input type="checkbox" class="form-check-input" name="sidecar" id="sidecar" />
<label class="form-check-label" for="sidecar" th:text="#{ocr.selectText.2}"></label>
</div> -->
<button type="submit" class="btn btn-primary" th:text="#{ocr.submit}"></button>
</form>
<p th:text="#{ocr.credit}"></p>
<p th:text="#{ocr.help}"></p>
<a href="https://github.com/Frooodle/Stirling-PDF/blob/main/HowToUseOCR.md">https://github.com/Frooodle/Stirling-PDF/blob/main/HowToUseOCR.md</a>
</div>
</div>
</div>
</div>
<div th:insert="~{fragments/footer.html :: footer}"></div>
</div>
</body>
</html>

View File

@ -22,7 +22,6 @@
</div>
<button type="submit" class="btn btn-primary" th:text="#{pdfOrganiser.submit}"></button>
</form>
<th:block th:insert="~{fragments/common :: filelist}"></th:block>
</div>
</div>

View File

@ -22,8 +22,6 @@
</div>
<button type="submit" class="btn btn-primary" th:text="#{pageRemover.submit}"></button>
</form>
<th:block th:insert="~{fragments/common :: filelist}"></th:block>
</div>
</div>
</div>

View File

@ -13,7 +13,7 @@
<div class="col-md-6">
<h2 th:text="#{remove-watermark.header}"></h2>
<form method="post" enctype="multipart/form-data" action="remove-text">
<form method="post" enctype="multipart/form-data" action="remove-watermark">
<div class="form-group">
<label th:text="#{remove-watermark.selectText.1}"></label>
<div th:replace="~{fragments/common :: fileSelector(name='fileInput', multiple=false)}"></div>

View File

@ -33,7 +33,6 @@
<br>
<button type="submit" class="btn btn-primary" th:text="#{split.submit}"></button>
</form>
<th:block th:insert="~{fragments/common :: filelist}"></th:block>
</div>
</div>
</div>