Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8343a6bd94 |
@@ -165,7 +165,10 @@ jobs:
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
allowed-endpoints: >
|
||||
one.digicert.com:443
|
||||
clientauth.one.digicert.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.parsers.ParserConfigurationException;
|
||||
import javax.xml.transform.OutputKeys;
|
||||
import javax.xml.transform.Transformer;
|
||||
import javax.xml.transform.TransformerException;
|
||||
import javax.xml.transform.TransformerFactory;
|
||||
import javax.xml.transform.dom.DOMSource;
|
||||
import javax.xml.transform.stream.StreamResult;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.NamedNodeMap;
|
||||
import org.w3c.dom.Node;
|
||||
import org.w3c.dom.NodeList;
|
||||
import org.xml.sax.SAXException;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.SsrfProtectionService;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class SvgSanitizer {
|
||||
|
||||
private static final Set<String> DANGEROUS_ELEMENTS =
|
||||
Set.of("script", "foreignobject", "iframe", "object", "embed", "handler", "listener");
|
||||
private static final Set<String> URL_ATTRIBUTES = Set.of("href", "xlink:href", "src", "data");
|
||||
private static final Pattern JAVASCRIPT_URL_PATTERN =
|
||||
Pattern.compile("^\\s*javascript\\s*:", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern DATA_SCRIPT_PATTERN =
|
||||
Pattern.compile(
|
||||
"^\\s*data\\s*:[^,]*(?:script|javascript|vbscript)", Pattern.CASE_INSENSITIVE);
|
||||
private final SsrfProtectionService ssrfProtectionService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public byte[] sanitize(byte[] svgBytes) throws IOException {
|
||||
if (svgBytes == null || svgBytes.length == 0) {
|
||||
throw new IOException("SVG input is empty or null");
|
||||
}
|
||||
|
||||
if (applicationProperties.getSystem().isDisableSanitize()) {
|
||||
log.debug("SVG sanitization disabled by configuration");
|
||||
return svgBytes;
|
||||
}
|
||||
|
||||
try {
|
||||
Document doc = parseSecurely(svgBytes);
|
||||
Element root = doc.getDocumentElement();
|
||||
if (root == null) {
|
||||
throw new IOException("SVG document has no root element");
|
||||
}
|
||||
|
||||
sanitizeNode(root);
|
||||
|
||||
byte[] result = serializeDocument(doc);
|
||||
if (result == null || result.length == 0) {
|
||||
throw new IOException("SVG sanitization produced empty output");
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (ParserConfigurationException | SAXException | TransformerException e) {
|
||||
throw new IOException("Failed to sanitize SVG content", e);
|
||||
}
|
||||
}
|
||||
|
||||
private Document parseSecurely(byte[] svgBytes)
|
||||
throws ParserConfigurationException, SAXException, IOException {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
|
||||
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
|
||||
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
|
||||
|
||||
factory.setXIncludeAware(false);
|
||||
factory.setExpandEntityReferences(false);
|
||||
factory.setNamespaceAware(true);
|
||||
|
||||
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||
return builder.parse(new ByteArrayInputStream(svgBytes));
|
||||
}
|
||||
|
||||
private void sanitizeNode(Node node) {
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
NodeList children = node.getChildNodes();
|
||||
Set<Node> nodesToRemove = new HashSet<>();
|
||||
|
||||
for (int i = 0; i < children.getLength(); i++) {
|
||||
Node child = children.item(i);
|
||||
if (child.getNodeType() == Node.ELEMENT_NODE) {
|
||||
String localName = child.getLocalName();
|
||||
if (localName == null) {
|
||||
localName = child.getNodeName();
|
||||
}
|
||||
|
||||
if (isDangerousElement(localName)) {
|
||||
log.warn("Removing dangerous SVG element: {}", localName);
|
||||
nodesToRemove.add(child);
|
||||
} else {
|
||||
sanitizeNode(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (Node toRemove : nodesToRemove) {
|
||||
node.removeChild(toRemove);
|
||||
}
|
||||
|
||||
if (node.getNodeType() == Node.ELEMENT_NODE) {
|
||||
sanitizeAttributes((Element) node);
|
||||
}
|
||||
}
|
||||
|
||||
private void sanitizeAttributes(Element element) {
|
||||
NamedNodeMap attributes = element.getAttributes();
|
||||
Set<String> attributesToRemove = new HashSet<>();
|
||||
|
||||
for (int i = 0; i < attributes.getLength(); i++) {
|
||||
Node attr = attributes.item(i);
|
||||
String attrName = attr.getNodeName().toLowerCase();
|
||||
String attrValue = attr.getNodeValue();
|
||||
|
||||
if (isEventHandler(attrName)) {
|
||||
log.warn("Removing event handler attribute: {}", attrName);
|
||||
attributesToRemove.add(attr.getNodeName());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isUrlAttribute(attrName)) {
|
||||
if (isDangerousUrl(attrValue)) {
|
||||
log.warn(
|
||||
"Removing dangerous URL in attribute {}: {}",
|
||||
attrName,
|
||||
truncateForLog(attrValue));
|
||||
attributesToRemove.add(attr.getNodeName());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isExternalUrl(attrValue) && !isUrlAllowed(attrValue)) {
|
||||
log.warn(
|
||||
"Removing SSRF-blocked URL in attribute {}: {}",
|
||||
attrName,
|
||||
truncateForLog(attrValue));
|
||||
attributesToRemove.add(attr.getNodeName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (String attrName : attributesToRemove) {
|
||||
element.removeAttribute(attrName);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isDangerousElement(String localName) {
|
||||
return DANGEROUS_ELEMENTS.contains(localName.toLowerCase());
|
||||
}
|
||||
|
||||
private boolean isEventHandler(String attrName) {
|
||||
return attrName.startsWith("on");
|
||||
}
|
||||
|
||||
private boolean isUrlAttribute(String attrName) {
|
||||
return URL_ATTRIBUTES.contains(attrName.toLowerCase())
|
||||
|| attrName.toLowerCase().endsWith(":href");
|
||||
}
|
||||
|
||||
private boolean isDangerousUrl(String url) {
|
||||
if (url == null || url.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String normalized = normalizeUrl(url);
|
||||
|
||||
if (JAVASCRIPT_URL_PATTERN.matcher(normalized).find()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DATA_SCRIPT_PATTERN.matcher(normalized).find()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private String normalizeUrl(String url) {
|
||||
if (url == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String result = url.trim();
|
||||
|
||||
result = result.replaceAll("\u0000", "");
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
try {
|
||||
String decoded = URLDecoder.decode(result, StandardCharsets.UTF_8);
|
||||
if (decoded.equals(result)) {
|
||||
break; // No more decoding needed
|
||||
}
|
||||
result = decoded;
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to decode URL, continuing with current value", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result.toLowerCase();
|
||||
}
|
||||
|
||||
private boolean isExternalUrl(String url) {
|
||||
if (url == null || url.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String normalized = normalizeUrl(url);
|
||||
|
||||
if (normalized.startsWith("#")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized.startsWith("data:")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalized.startsWith("http://")
|
||||
|| normalized.startsWith("https://")
|
||||
|| normalized.startsWith("//")
|
||||
|| normalized.startsWith("file:");
|
||||
}
|
||||
|
||||
private boolean isUrlAllowed(String url) {
|
||||
if (ssrfProtectionService == null) {
|
||||
return true;
|
||||
}
|
||||
return ssrfProtectionService.isUrlAllowed(url);
|
||||
}
|
||||
|
||||
private byte[] serializeDocument(Document doc) throws TransformerException {
|
||||
TransformerFactory transformerFactory = TransformerFactory.newInstance();
|
||||
transformerFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
|
||||
Transformer transformer = transformerFactory.newTransformer();
|
||||
transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
|
||||
transformer.setOutputProperty(OutputKeys.INDENT, "no");
|
||||
transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no");
|
||||
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
transformer.transform(new DOMSource(doc), new StreamResult(outputStream));
|
||||
|
||||
return outputStream.toByteArray();
|
||||
}
|
||||
|
||||
private String truncateForLog(String value) {
|
||||
if (value == null) {
|
||||
return "null";
|
||||
}
|
||||
return value.length() > 50 ? value.substring(0, 50) + "..." : value;
|
||||
}
|
||||
}
|
||||
@@ -91,9 +91,6 @@ dependencies {
|
||||
// Batik
|
||||
implementation 'org.apache.xmlgraphics:batik-all:1.19'
|
||||
|
||||
// PDFBox Graphics2D bridge for Batik SVG to PDF conversion
|
||||
implementation 'de.rototor.pdfbox:graphics2d:3.0.5'
|
||||
|
||||
// TwelveMonkeys
|
||||
runtimeOnly "com.twelvemonkeys.imageio:imageio-batik:$imageioVersion"
|
||||
runtimeOnly "com.twelvemonkeys.imageio:imageio-bmp:$imageioVersion"
|
||||
|
||||
-244
@@ -1,244 +0,0 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.MultiFileResponse;
|
||||
import stirling.software.SPDF.model.api.converters.SvgToPdfRequest;
|
||||
import stirling.software.SPDF.utils.SvgToPdf;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.SvgSanitizer;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@ConvertApi
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ConvertSvgToPDF {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final SvgSanitizer svgSanitizer;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/svg/pdf")
|
||||
@MultiFileResponse
|
||||
@Operation(
|
||||
summary = "Convert SVG to PDF",
|
||||
description =
|
||||
"This endpoint converts one or more SVG (Scalable Vector Graphics) files to PDF format. "
|
||||
+ "Each SVG is converted to a separate PDF file. "
|
||||
+ "The conversion preserves vector graphics for crisp output at any resolution - no rasterization occurs. "
|
||||
+ "SVG dimensions (width/height) determine the PDF page size; defaults to A4 if not specified. "
|
||||
+ "SVG content is sanitized to prevent XSS attacks. "
|
||||
+ "Input: SVG file(s), Output: PDF file(s) or ZIP. Type: MIMO")
|
||||
public ResponseEntity<byte[]> convertSvgToPdf(@ModelAttribute SvgToPdfRequest request) {
|
||||
|
||||
MultipartFile[] inputFiles = request.getFileInput();
|
||||
boolean combineIntoSinglePdf = Boolean.TRUE.equals(request.getCombineIntoSinglePdf());
|
||||
|
||||
// Validate input
|
||||
if (inputFiles == null || inputFiles.length == 0) {
|
||||
log.error("No files provided for SVG to PDF conversion.");
|
||||
return ResponseEntity.badRequest()
|
||||
.body("No files provided".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
try {
|
||||
List<byte[]> sanitizedSvgs = new ArrayList<>();
|
||||
List<String> filenames = new ArrayList<>();
|
||||
|
||||
for (MultipartFile inputFile : inputFiles) {
|
||||
if (inputFile == null || inputFile.isEmpty()) {
|
||||
log.warn("Skipping empty file in batch conversion");
|
||||
continue;
|
||||
}
|
||||
|
||||
String originalFilename = inputFile.getOriginalFilename();
|
||||
if (originalFilename == null || originalFilename.trim().isEmpty()) {
|
||||
log.warn("Skipping file with null or empty filename");
|
||||
continue;
|
||||
}
|
||||
|
||||
String lowerFilename = originalFilename.toLowerCase(Locale.ROOT);
|
||||
if (!lowerFilename.endsWith(".svg")) {
|
||||
log.warn("Skipping non-SVG file: {}", originalFilename);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] fileBytes = inputFile.getBytes();
|
||||
byte[] sanitizedBytes = svgSanitizer.sanitize(fileBytes);
|
||||
sanitizedSvgs.add(sanitizedBytes);
|
||||
filenames.add(Filenames.toSimpleFileName(originalFilename));
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
"SVG sanitization/reading failed for {}: {}",
|
||||
originalFilename,
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (sanitizedSvgs.isEmpty()) {
|
||||
log.error("No valid SVG files were found");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body("No valid SVG files were found".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
if (combineIntoSinglePdf) {
|
||||
return handleCombinedConversion(sanitizedSvgs, filenames);
|
||||
} else {
|
||||
return handleSeparateConversion(sanitizedSvgs, filenames);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Unexpected error during SVG to PDF conversion", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
"An unexpected error occurred during conversion"
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> handleCombinedConversion(
|
||||
List<byte[]> sanitizedSvgs, List<String> filenames) {
|
||||
try {
|
||||
log.info("Combining {} SVG files into single PDF", sanitizedSvgs.size());
|
||||
|
||||
byte[] pdfBytes = SvgToPdf.combineIntoPdf(sanitizedSvgs);
|
||||
|
||||
if (pdfBytes == null || pdfBytes.length == 0) {
|
||||
log.error("PDF conversion failed - empty output");
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
"PDF conversion failed - empty output"
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
pdfBytes = pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes);
|
||||
|
||||
String outputFilename =
|
||||
filenames.isEmpty()
|
||||
? "combined_svgs.pdf"
|
||||
: GeneralUtils.generateFilename(filenames.get(0), "_combined.pdf");
|
||||
|
||||
log.info("Successfully combined {} SVGs into single PDF", sanitizedSvgs.size());
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
pdfBytes, outputFilename, MediaType.APPLICATION_PDF);
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("Error combining SVGs into PDF", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
("Conversion failed: " + e.getMessage())
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> handleSeparateConversion(
|
||||
List<byte[]> sanitizedSvgs, List<String> filenames) {
|
||||
List<ConvertedPdf> convertedPdfs = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < sanitizedSvgs.size(); i++) {
|
||||
byte[] sanitizedBytes = sanitizedSvgs.get(i);
|
||||
String baseFilename = filenames.get(i);
|
||||
|
||||
try {
|
||||
byte[] pdfBytes = SvgToPdf.convert(sanitizedBytes);
|
||||
|
||||
if (pdfBytes == null || pdfBytes.length == 0) {
|
||||
log.error("PDF conversion failed - empty output for {}", baseFilename);
|
||||
continue;
|
||||
}
|
||||
|
||||
pdfBytes = pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes);
|
||||
|
||||
String outputFilename = GeneralUtils.generateFilename(baseFilename, ".pdf");
|
||||
convertedPdfs.add(new ConvertedPdf(outputFilename, pdfBytes));
|
||||
|
||||
log.info("Successfully converted SVG to PDF: {}", baseFilename);
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("File processing error for SVG to PDF: {}", baseFilename, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (convertedPdfs.isEmpty()) {
|
||||
log.error("No files were successfully converted");
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("No files were successfully converted".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
try {
|
||||
if (convertedPdfs.size() == 1) {
|
||||
ConvertedPdf pdf = convertedPdfs.get(0);
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
pdf.content, pdf.filename, MediaType.APPLICATION_PDF);
|
||||
}
|
||||
|
||||
String zipFilename =
|
||||
filenames.isEmpty()
|
||||
? "converted_svgs.zip"
|
||||
: GeneralUtils.generateFilename(
|
||||
filenames.get(0), "_converted_svgs.zip");
|
||||
byte[] zipBytes = createZipFromPdfs(convertedPdfs);
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
zipBytes, zipFilename, MediaType.APPLICATION_OCTET_STREAM);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to create response", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("Failed to create response".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] createZipFromPdfs(List<ConvertedPdf> pdfs) throws IOException {
|
||||
try (TempFile tempZipFile = new TempFile(tempFileManager, ".zip");
|
||||
ZipOutputStream zipOut =
|
||||
new ZipOutputStream(Files.newOutputStream(tempZipFile.getPath()))) {
|
||||
|
||||
for (ConvertedPdf pdf : pdfs) {
|
||||
ZipEntry pdfEntry = new ZipEntry(pdf.filename);
|
||||
zipOut.putNextEntry(pdfEntry);
|
||||
zipOut.write(pdf.content);
|
||||
zipOut.closeEntry();
|
||||
log.debug("Added {} to ZIP", pdf.filename);
|
||||
}
|
||||
|
||||
return Files.readAllBytes(tempZipFile.getPath());
|
||||
}
|
||||
}
|
||||
|
||||
private static class ConvertedPdf {
|
||||
final String filename;
|
||||
final byte[] content;
|
||||
|
||||
ConvertedPdf(String filename, byte[] content) {
|
||||
this.filename = filename;
|
||||
this.content = content;
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-50
@@ -1,12 +1,7 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -19,11 +14,11 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.OverlayImageRequest;
|
||||
import stirling.software.SPDF.utils.SvgOverlayUtil;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.MiscApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.PdfUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@MiscApi
|
||||
@@ -37,63 +32,25 @@ public class OverlayImageController {
|
||||
@Operation(
|
||||
summary = "Overlay image onto a PDF file",
|
||||
description =
|
||||
"This endpoint overlays an image onto a PDF file at the specified coordinates. "
|
||||
+ "Supports both raster formats (PNG, JPEG, etc.) and vector format (SVG). "
|
||||
+ "SVG files are rendered as vector graphics for crisp output at any resolution. "
|
||||
+ "The image can be overlaid on every page of the PDF if specified. "
|
||||
+ "Input:PDF/IMAGE/SVG Output:PDF Type:SISO")
|
||||
"This endpoint overlays an image onto a PDF file at the specified coordinates."
|
||||
+ " The image can be overlaid on every page of the PDF if specified. "
|
||||
+ " Input:PDF/IMAGE Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> overlayImage(@ModelAttribute OverlayImageRequest request) {
|
||||
MultipartFile pdfFile = request.getFileInput();
|
||||
MultipartFile imageFile = request.getImageFile();
|
||||
float x = request.getX();
|
||||
float y = request.getY();
|
||||
boolean everyPage = Boolean.TRUE.equals(request.getEveryPage());
|
||||
|
||||
try {
|
||||
byte[] pdfBytes = pdfFile.getBytes();
|
||||
byte[] imageBytes = imageFile.getBytes();
|
||||
|
||||
boolean isSvg = SvgOverlayUtil.isSvgImage(imageBytes);
|
||||
|
||||
PDDocument document = pdfDocumentFactory.load(pdfBytes);
|
||||
|
||||
int pages = document.getNumberOfPages();
|
||||
for (int i = 0; i < pages; i++) {
|
||||
PDPage page = document.getPage(i);
|
||||
|
||||
if (isSvg) {
|
||||
SvgOverlayUtil.overlaySvgOnPage(document, page, imageBytes, x, y);
|
||||
} else {
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true)) {
|
||||
PDImageXObject image =
|
||||
PDImageXObject.createFromByteArray(document, imageBytes, "");
|
||||
contentStream.drawImage(image, x, y);
|
||||
log.info("Image successfully overlaid onto PDF page {}", i);
|
||||
}
|
||||
}
|
||||
|
||||
if (!everyPage && i == 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
document.close();
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
log.info("PDF with overlaid image successfully created");
|
||||
byte[] result =
|
||||
PdfUtils.overlayImage(
|
||||
pdfDocumentFactory, pdfBytes, imageBytes, x, y, everyPage);
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_overlayed.pdf"));
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to add image to PDF", e);
|
||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||
|
||||
+10
-10
@@ -1,6 +1,9 @@
|
||||
package stirling.software.SPDF.controller.api.pipeline;
|
||||
|
||||
import java.io.*;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileSystemException;
|
||||
import java.nio.file.FileVisitResult;
|
||||
@@ -21,6 +24,7 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -160,8 +164,7 @@ public class PipelineDirectoryProcessor {
|
||||
postHogService.captureEvent("pipeline_directory_event", properties);
|
||||
|
||||
List<File> filesToProcess = prepareFilesForProcessing(files, processingDir);
|
||||
try (PipelineResult result =
|
||||
runPipelineAgainstFiles(filesToProcess, config, dir, processingDir)) {}
|
||||
runPipelineAgainstFiles(filesToProcess, config, dir, processingDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,14 +303,14 @@ public class PipelineDirectoryProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
private PipelineResult runPipelineAgainstFiles(
|
||||
private void runPipelineAgainstFiles(
|
||||
List<File> filesToProcess, PipelineConfig config, Path dir, Path processingDir)
|
||||
throws IOException {
|
||||
try {
|
||||
List<Resource> inputFiles =
|
||||
processor.generateInputFiles(filesToProcess.toArray(new File[0]));
|
||||
if (inputFiles == null || inputFiles.isEmpty()) {
|
||||
return new PipelineResult();
|
||||
return;
|
||||
}
|
||||
PipelineResult result = processor.runPipelineAgainstFiles(inputFiles, config);
|
||||
|
||||
@@ -318,11 +321,9 @@ public class PipelineDirectoryProcessor {
|
||||
moveAndRenameFiles(result.getOutputFiles(), config, dir);
|
||||
deleteOriginalFiles(filesToProcess, processingDir);
|
||||
}
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
log.error("Error during processing", e);
|
||||
moveFilesBack(filesToProcess, processingDir);
|
||||
return new PipelineResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,9 +350,8 @@ public class PipelineDirectoryProcessor {
|
||||
log.info("Created directory: {}", outputPath);
|
||||
}
|
||||
Path outputFile = outputPath.resolve(outputFileName);
|
||||
try (OutputStream os = new FileOutputStream(outputFile.toFile());
|
||||
InputStream is = resource.getInputStream()) {
|
||||
is.transferTo(os);
|
||||
try (OutputStream os = new FileOutputStream(outputFile.toFile())) {
|
||||
os.write(((ByteArrayResource) resource).getByteArray());
|
||||
}
|
||||
log.info("File moved and renamed to {}", outputFile);
|
||||
}
|
||||
|
||||
+40
-102
@@ -15,10 +15,9 @@ import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.http.converter.FormHttpMessageConverter;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
@@ -39,8 +38,6 @@ import stirling.software.SPDF.model.PipelineResult;
|
||||
import stirling.software.SPDF.service.ApiDocService;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@@ -52,17 +49,13 @@ public class PipelineProcessor {
|
||||
|
||||
private final ServletContext servletContext;
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
public PipelineProcessor(
|
||||
ApiDocService apiDocService,
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
ServletContext servletContext,
|
||||
TempFileManager tempFileManager) {
|
||||
ServletContext servletContext) {
|
||||
this.apiDocService = apiDocService;
|
||||
this.userService = userService;
|
||||
this.servletContext = servletContext;
|
||||
this.tempFileManager = tempFileManager;
|
||||
}
|
||||
|
||||
public static String removeTrailingNaming(String filename) {
|
||||
@@ -144,18 +137,14 @@ public class PipelineProcessor {
|
||||
body.add(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
ResponseEntity<Resource> response = sendWebRequest(url, body);
|
||||
ResponseEntity<byte[]> response = sendWebRequest(url, body);
|
||||
// If the operation is filter and the response body is null or empty,
|
||||
// skip
|
||||
// this
|
||||
// file
|
||||
if (response.getBody() instanceof TempFileResource tempFileResource) {
|
||||
result.addTempFile(tempFileResource.getTempFile());
|
||||
}
|
||||
|
||||
if (operation.startsWith("/api/v1/filter/filter-")
|
||||
&& (response.getBody() == null
|
||||
|| response.getBody().contentLength() == 0)) {
|
||||
|| response.getBody().length == 0)) {
|
||||
filtersApplied = true;
|
||||
log.info("Skipping file due to filtering {}", operation);
|
||||
continue;
|
||||
@@ -165,7 +154,7 @@ public class PipelineProcessor {
|
||||
hasErrors = true;
|
||||
continue;
|
||||
}
|
||||
processOutputFiles(operation, response, newOutputFiles, result);
|
||||
processOutputFiles(operation, response, newOutputFiles);
|
||||
}
|
||||
}
|
||||
if (!hasInputFileType) {
|
||||
@@ -226,13 +215,10 @@ public class PipelineProcessor {
|
||||
body.add(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
ResponseEntity<Resource> response = sendWebRequest(url, body);
|
||||
if (response.getBody() instanceof TempFileResource tempFileResource) {
|
||||
result.addTempFile(tempFileResource.getTempFile());
|
||||
}
|
||||
ResponseEntity<byte[]> response = sendWebRequest(url, body);
|
||||
// Handle the response
|
||||
if (HttpStatus.OK.equals(response.getStatusCode())) {
|
||||
processOutputFiles(operation, response, newOutputFiles, result);
|
||||
processOutputFiles(operation, response, newOutputFiles);
|
||||
} else {
|
||||
// Log error if the response status is not OK
|
||||
logPrintStream.println(
|
||||
@@ -281,7 +267,7 @@ public class PipelineProcessor {
|
||||
return result;
|
||||
}
|
||||
|
||||
/* package */ ResponseEntity<Resource> sendWebRequest(
|
||||
/* package */ ResponseEntity<byte[]> sendWebRequest(
|
||||
String url, MultiValueMap<String, Object> body) {
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
// Set up headers, including API key
|
||||
@@ -289,37 +275,14 @@ public class PipelineProcessor {
|
||||
String apiKey = getApiKeyForUser();
|
||||
headers.add("X-API-KEY", apiKey);
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
|
||||
return restTemplate.execute(
|
||||
url,
|
||||
HttpMethod.POST,
|
||||
request -> {
|
||||
request.getHeaders().putAll(headers);
|
||||
new FormHttpMessageConverter()
|
||||
.write(body, MediaType.MULTIPART_FORM_DATA, request);
|
||||
},
|
||||
response -> {
|
||||
try {
|
||||
TempFile tempFile = tempFileManager.createManagedTempFile("pipeline");
|
||||
Files.copy(
|
||||
response.getBody(),
|
||||
tempFile.getPath(),
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
TempFileResource resource = new TempFileResource(tempFile);
|
||||
return ResponseEntity.status(response.getStatusCode())
|
||||
.headers(response.getHeaders())
|
||||
.body(resource);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
});
|
||||
// Create HttpEntity with the body and headers
|
||||
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
|
||||
// Make the request to the REST endpoint
|
||||
return restTemplate.exchange(url, HttpMethod.POST, entity, byte[].class);
|
||||
}
|
||||
|
||||
private List<Resource> processOutputFiles(
|
||||
String operation,
|
||||
ResponseEntity<Resource> response,
|
||||
List<Resource> newOutputFiles,
|
||||
PipelineResult result)
|
||||
String operation, ResponseEntity<byte[]> response, List<Resource> newOutputFiles)
|
||||
throws IOException {
|
||||
// Define filename
|
||||
String newFilename;
|
||||
@@ -335,14 +298,10 @@ public class PipelineProcessor {
|
||||
// Check if the response body is a zip file
|
||||
if (isZip(response.getBody(), newFilename)) {
|
||||
// Unzip the file and add all the files to the new output files
|
||||
newOutputFiles.addAll(unzip(response.getBody(), result));
|
||||
newOutputFiles.addAll(unzip(response.getBody()));
|
||||
} else {
|
||||
final Resource tempResource = response.getBody();
|
||||
if (tempResource instanceof TempFileResource) {
|
||||
result.addTempFile(((TempFileResource) tempResource).getTempFile());
|
||||
}
|
||||
Resource outputResource =
|
||||
new FileSystemResource(tempResource.getFile()) {
|
||||
new ByteArrayResource(response.getBody()) {
|
||||
|
||||
@Override
|
||||
public String getFilename() {
|
||||
@@ -354,7 +313,7 @@ public class PipelineProcessor {
|
||||
return newOutputFiles;
|
||||
}
|
||||
|
||||
public String extractFilename(ResponseEntity<Resource> response) {
|
||||
public String extractFilename(ResponseEntity<byte[]> response) {
|
||||
// Default filename if not found
|
||||
String filename = "default-filename.ext";
|
||||
HttpHeaders headers = response.getHeaders();
|
||||
@@ -389,7 +348,14 @@ public class PipelineProcessor {
|
||||
// debug statement
|
||||
log.info("Reading file: {}", path);
|
||||
if (Files.exists(path)) {
|
||||
Resource fileResource = new FileSystemResource(file);
|
||||
Resource fileResource =
|
||||
new ByteArrayResource(Files.readAllBytes(path)) {
|
||||
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return file.getName();
|
||||
}
|
||||
};
|
||||
outputFiles.add(fileResource);
|
||||
} else {
|
||||
log.info("File not found: {}", path);
|
||||
@@ -406,11 +372,8 @@ public class PipelineProcessor {
|
||||
}
|
||||
List<Resource> outputFiles = new ArrayList<>();
|
||||
for (MultipartFile file : files) {
|
||||
Path tempFile = Files.createTempFile("SPDF-upload-", ".tmp");
|
||||
file.transferTo(tempFile);
|
||||
|
||||
Resource fileResource =
|
||||
new FileSystemResource(tempFile.toFile()) {
|
||||
new ByteArrayResource(file.getBytes()) {
|
||||
|
||||
@Override
|
||||
public String getFilename() {
|
||||
@@ -423,8 +386,8 @@ public class PipelineProcessor {
|
||||
return outputFiles;
|
||||
}
|
||||
|
||||
private boolean isZip(Resource data, String filename) throws IOException {
|
||||
if (data == null || data.contentLength() < 4) {
|
||||
private boolean isZip(byte[] data, String filename) {
|
||||
if (data == null || data.length < 4) {
|
||||
return false;
|
||||
}
|
||||
if (filename != null) {
|
||||
@@ -435,41 +398,29 @@ public class PipelineProcessor {
|
||||
}
|
||||
}
|
||||
// Check the first four bytes of the data against the standard zip magic number
|
||||
try (InputStream is = data.getInputStream()) {
|
||||
byte[] header = new byte[4];
|
||||
if (is.read(header) < 4) {
|
||||
return false;
|
||||
}
|
||||
return header[0] == 0x50 && header[1] == 0x4B && header[2] == 0x03 && header[3] == 0x04;
|
||||
}
|
||||
return data[0] == 0x50 && data[1] == 0x4B && data[2] == 0x03 && data[3] == 0x04;
|
||||
}
|
||||
|
||||
private boolean isZip(Resource data) throws IOException {
|
||||
private boolean isZip(byte[] data) {
|
||||
return isZip(data, null);
|
||||
}
|
||||
|
||||
private List<Resource> unzip(Resource data, PipelineResult result) throws IOException {
|
||||
log.info("Unzipping data of length: {}", data.contentLength());
|
||||
private List<Resource> unzip(byte[] data) throws IOException {
|
||||
log.info("Unzipping data of length: {}", data.length);
|
||||
List<Resource> unzippedFiles = new ArrayList<>();
|
||||
try (InputStream bais = data.getInputStream();
|
||||
try (ByteArrayInputStream bais = new ByteArrayInputStream(data);
|
||||
ZipInputStream zis = ZipSecurity.createHardenedInputStream(bais)) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
if (entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
TempFile tempFile = tempFileManager.createManagedTempFile("unzip");
|
||||
result.addTempFile(tempFile);
|
||||
try (OutputStream os = Files.newOutputStream(tempFile.getPath())) {
|
||||
byte[] buffer = new byte[4096];
|
||||
int count;
|
||||
while ((count = zis.read(buffer)) != -1) {
|
||||
os.write(buffer, 0, count);
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[1024];
|
||||
int count;
|
||||
while ((count = zis.read(buffer)) != -1) {
|
||||
baos.write(buffer, 0, count);
|
||||
}
|
||||
final String filename = entry.getName();
|
||||
Resource fileResource =
|
||||
new FileSystemResource(tempFile.getFile()) {
|
||||
new ByteArrayResource(baos.toByteArray()) {
|
||||
|
||||
@Override
|
||||
public String getFilename() {
|
||||
@@ -477,9 +428,9 @@ public class PipelineProcessor {
|
||||
}
|
||||
};
|
||||
// If the unzipped file is a zip file, unzip it
|
||||
if (isZip(fileResource, filename)) {
|
||||
if (isZip(baos.toByteArray(), filename)) {
|
||||
log.info("File {} is a zip file. Unzipping...", filename);
|
||||
unzippedFiles.addAll(unzip(fileResource, result));
|
||||
unzippedFiles.addAll(unzip(baos.toByteArray()));
|
||||
} else {
|
||||
unzippedFiles.add(fileResource);
|
||||
}
|
||||
@@ -488,17 +439,4 @@ public class PipelineProcessor {
|
||||
log.info("Unzipping completed. {} files were unzipped.", unzippedFiles.size());
|
||||
return unzippedFiles;
|
||||
}
|
||||
|
||||
private static class TempFileResource extends FileSystemResource {
|
||||
private final TempFile tempFile;
|
||||
|
||||
public TempFileResource(TempFile tempFile) {
|
||||
super(tempFile.getFile());
|
||||
this.tempFile = tempFile;
|
||||
}
|
||||
|
||||
public TempFile getTempFile() {
|
||||
return tempFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,14 @@
|
||||
package stirling.software.SPDF.model;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.util.TempFile;
|
||||
|
||||
@Data
|
||||
@Slf4j
|
||||
public class PipelineResult implements AutoCloseable {
|
||||
public class PipelineResult {
|
||||
private List<Resource> outputFiles;
|
||||
private boolean hasErrors;
|
||||
private boolean filtersApplied;
|
||||
private List<TempFile> tempFiles = new ArrayList<>();
|
||||
|
||||
public void addTempFile(TempFile tempFile) {
|
||||
tempFiles.add(tempFile);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
for (TempFile file : tempFiles) {
|
||||
file.close();
|
||||
log.debug("Deleted temp file: {}", file.getAbsolutePath());
|
||||
}
|
||||
tempFiles.clear();
|
||||
}
|
||||
|
||||
public void cleanup() {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
package stirling.software.SPDF.model.api.converters;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode
|
||||
public class SvgToPdfRequest {
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"The SVG file(s) to be converted to PDF. "
|
||||
+ "SVGs are scalable and have inherent dimensions - the conversion uses these dimensions "
|
||||
+ "to determine the PDF page size. If dimensions are not specified in the SVG, A4 size is used.",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private MultipartFile[] fileInput;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Whether to combine all SVG files into a single PDF (each SVG as a separate page) "
|
||||
+ "or create separate PDF files for each SVG.",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
defaultValue = "false")
|
||||
private Boolean combineIntoSinglePdf;
|
||||
}
|
||||
+1
-4
@@ -14,10 +14,7 @@ import stirling.software.common.model.api.PDFFile;
|
||||
public class OverlayImageRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"The image file to be overlaid onto the PDF. "
|
||||
+ "Supports raster formats (PNG, JPEG, etc.) and vector format (SVG). "
|
||||
+ "SVG files are rendered as vector graphics for crisp output at any resolution.",
|
||||
description = "The image file to be overlaid onto the PDF.",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
format = "binary")
|
||||
private MultipartFile imageFile;
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
package stirling.software.SPDF.utils;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
import org.apache.batik.anim.dom.SAXSVGDocumentFactory;
|
||||
import org.apache.batik.bridge.BridgeContext;
|
||||
import org.apache.batik.bridge.DocumentLoader;
|
||||
import org.apache.batik.bridge.GVTBuilder;
|
||||
import org.apache.batik.bridge.UserAgent;
|
||||
import org.apache.batik.bridge.UserAgentAdapter;
|
||||
import org.apache.batik.gvt.GraphicsNode;
|
||||
import org.apache.batik.util.XMLResourceDescriptor;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.util.Matrix;
|
||||
import org.w3c.dom.svg.SVGDocument;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import de.rototor.pdfbox.graphics2d.PdfBoxGraphics2D;
|
||||
|
||||
@UtilityClass
|
||||
@Slf4j
|
||||
public class SvgOverlayUtil {
|
||||
|
||||
public void overlaySvgOnPage(
|
||||
PDDocument document, PDPage page, byte[] svgBytes, float x, float y)
|
||||
throws IOException {
|
||||
try {
|
||||
String parser = XMLResourceDescriptor.getXMLParserClassName();
|
||||
SAXSVGDocumentFactory factory = new SAXSVGDocumentFactory(parser);
|
||||
|
||||
SVGDocument svgDoc;
|
||||
try (ByteArrayInputStream inputStream = new ByteArrayInputStream(svgBytes)) {
|
||||
svgDoc = factory.createSVGDocument("file:///overlay.svg", inputStream);
|
||||
}
|
||||
|
||||
UserAgent userAgent = new UserAgentAdapter();
|
||||
DocumentLoader loader = new DocumentLoader(userAgent);
|
||||
BridgeContext ctx = new BridgeContext(userAgent, loader);
|
||||
ctx.setDynamicState(BridgeContext.DYNAMIC);
|
||||
|
||||
GVTBuilder builder = new GVTBuilder();
|
||||
GraphicsNode rootNode = builder.build(ctx, svgDoc);
|
||||
|
||||
float svgWidth = (float) ctx.getDocumentSize().getWidth();
|
||||
float svgHeight = (float) ctx.getDocumentSize().getHeight();
|
||||
|
||||
PdfBoxGraphics2D pdfGraphics = new PdfBoxGraphics2D(document, svgWidth, svgHeight);
|
||||
|
||||
try {
|
||||
rootNode.paint(pdfGraphics);
|
||||
} finally {
|
||||
pdfGraphics.dispose();
|
||||
}
|
||||
|
||||
PDFormXObject xform = pdfGraphics.getXFormObject();
|
||||
|
||||
try (PDPageContentStream newContentStream =
|
||||
new PDPageContentStream(
|
||||
document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
|
||||
newContentStream.saveGraphicsState();
|
||||
|
||||
newContentStream.transform(new Matrix(1, 0, 0, 1, x, y));
|
||||
|
||||
newContentStream.drawForm(xform);
|
||||
|
||||
newContentStream.restoreGraphicsState();
|
||||
}
|
||||
|
||||
log.info("SVG successfully overlaid as vector graphic at ({}, {})", x, y);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to overlay SVG as vector graphic", e);
|
||||
throw new IOException("SVG overlay failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isSvgImage(byte[] bytes) {
|
||||
if (bytes == null || bytes.length < 5) {
|
||||
return false;
|
||||
}
|
||||
// Check for SVG markers: <?xml or <svg
|
||||
String start =
|
||||
new String(
|
||||
bytes,
|
||||
0,
|
||||
Math.min(200, bytes.length),
|
||||
java.nio.charset.StandardCharsets.UTF_8)
|
||||
.toLowerCase();
|
||||
return start.contains("<svg") || (start.contains("<?xml") && start.contains("svg"));
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
package stirling.software.SPDF.utils;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import org.apache.batik.anim.dom.SAXSVGDocumentFactory;
|
||||
import org.apache.batik.bridge.BridgeContext;
|
||||
import org.apache.batik.bridge.DocumentLoader;
|
||||
import org.apache.batik.bridge.GVTBuilder;
|
||||
import org.apache.batik.bridge.UserAgent;
|
||||
import org.apache.batik.bridge.UserAgentAdapter;
|
||||
import org.apache.batik.gvt.GraphicsNode;
|
||||
import org.apache.batik.util.XMLResourceDescriptor;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.w3c.dom.svg.SVGDocument;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import de.rototor.pdfbox.graphics2d.PdfBoxGraphics2D;
|
||||
|
||||
@UtilityClass
|
||||
@Slf4j
|
||||
public class SvgToPdf {
|
||||
|
||||
/** Default page width in points (A4) */
|
||||
private static final float DEFAULT_PAGE_WIDTH = 595f;
|
||||
|
||||
/** Default page height in points (A4) */
|
||||
private static final float DEFAULT_PAGE_HEIGHT = 842f;
|
||||
|
||||
/** Timeout for SVG rendering in seconds (prevents DoS via complex SVGs) */
|
||||
private static final int RENDERING_TIMEOUT_SECONDS = 30;
|
||||
|
||||
public byte[] convert(byte[] svgBytes) throws IOException {
|
||||
if (svgBytes == null || svgBytes.length == 0) {
|
||||
throw new IOException("SVG input is empty or null");
|
||||
}
|
||||
|
||||
log.debug("Starting SVG to PDF conversion, input size: {} bytes", svgBytes.length);
|
||||
|
||||
try {
|
||||
// 1. Load SVG using Batik
|
||||
String parser = XMLResourceDescriptor.getXMLParserClassName();
|
||||
SAXSVGDocumentFactory factory = new SAXSVGDocumentFactory(parser);
|
||||
|
||||
SVGDocument svgDoc;
|
||||
try (ByteArrayInputStream inputStream = new ByteArrayInputStream(svgBytes)) {
|
||||
svgDoc = factory.createSVGDocument("file:///input.svg", inputStream);
|
||||
}
|
||||
|
||||
// 2. Build the GVT (Graphics Vector Tree) with timeout protection
|
||||
UserAgent userAgent = new UserAgentAdapter();
|
||||
DocumentLoader loader = new DocumentLoader(userAgent);
|
||||
BridgeContext ctx = new BridgeContext(userAgent, loader);
|
||||
ctx.setDynamicState(BridgeContext.DYNAMIC);
|
||||
|
||||
GraphicsNode rootNode = buildGvtWithTimeout(ctx, svgDoc);
|
||||
|
||||
// 3. Get SVG dimensions
|
||||
float width = (float) ctx.getDocumentSize().getWidth();
|
||||
float height = (float) ctx.getDocumentSize().getHeight();
|
||||
|
||||
if (width <= 0) {
|
||||
width = DEFAULT_PAGE_WIDTH;
|
||||
log.warn("SVG width not specified, using default: {}", width);
|
||||
}
|
||||
if (height <= 0) {
|
||||
height = DEFAULT_PAGE_HEIGHT;
|
||||
log.warn("SVG height not specified, using default: {}", height);
|
||||
}
|
||||
|
||||
log.debug("SVG dimensions: {}x{} points", width, height);
|
||||
|
||||
// 4. Create PDF document and render
|
||||
return renderToPdf(rootNode, width, height);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to convert SVG to PDF", e);
|
||||
throw new IOException("SVG to PDF conversion failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private GraphicsNode buildGvtWithTimeout(BridgeContext ctx, SVGDocument svgDoc)
|
||||
throws IOException {
|
||||
GVTBuilder builder = new GVTBuilder();
|
||||
ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
|
||||
Callable<GraphicsNode> buildTask = () -> builder.build(ctx, svgDoc);
|
||||
Future<GraphicsNode> future = executor.submit(buildTask);
|
||||
|
||||
try {
|
||||
return future.get(RENDERING_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
} catch (TimeoutException e) {
|
||||
future.cancel(true);
|
||||
throw new IOException(
|
||||
"SVG rendering timed out after "
|
||||
+ RENDERING_TIMEOUT_SECONDS
|
||||
+ " seconds. The SVG may be too complex.");
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("SVG rendering was interrupted", e);
|
||||
} catch (ExecutionException e) {
|
||||
Throwable cause = e.getCause();
|
||||
throw new IOException(
|
||||
"SVG rendering failed: "
|
||||
+ (cause != null ? cause.getMessage() : e.getMessage()),
|
||||
cause);
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] renderToPdf(GraphicsNode rootNode, float width, float height)
|
||||
throws IOException {
|
||||
try (PDDocument document = new PDDocument();
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||
|
||||
PDPage page = new PDPage(new PDRectangle(width, height));
|
||||
document.addPage(page);
|
||||
|
||||
// Create and use PdfBoxGraphics2D with proper resource management
|
||||
PdfBoxGraphics2D pdfGraphics = new PdfBoxGraphics2D(document, width, height);
|
||||
try {
|
||||
rootNode.paint(pdfGraphics);
|
||||
} finally {
|
||||
pdfGraphics.dispose();
|
||||
}
|
||||
|
||||
PDFormXObject xform = pdfGraphics.getXFormObject();
|
||||
try (PDPageContentStream contentStream = new PDPageContentStream(document, page)) {
|
||||
contentStream.drawForm(xform);
|
||||
}
|
||||
|
||||
document.save(outputStream);
|
||||
|
||||
byte[] result = outputStream.toByteArray();
|
||||
log.debug("SVG to PDF conversion complete, output size: {} bytes", result.length);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] combineIntoPdf(List<byte[]> svgBytesList) throws IOException {
|
||||
if (svgBytesList == null || svgBytesList.isEmpty()) {
|
||||
throw new IOException("SVG list is empty or null");
|
||||
}
|
||||
|
||||
log.debug("Combining {} SVG files into single PDF", svgBytesList.size());
|
||||
|
||||
try (PDDocument document = new PDDocument();
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||
|
||||
for (int i = 0; i < svgBytesList.size(); i++) {
|
||||
byte[] svgBytes = svgBytesList.get(i);
|
||||
if (svgBytes == null || svgBytes.length == 0) {
|
||||
log.warn("Skipping empty SVG at index {}", i);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
addSvgAsPage(document, svgBytes);
|
||||
log.debug("Added SVG {} of {} to combined PDF", i + 1, svgBytesList.size());
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to add SVG {} to combined PDF: {}", i, e.getMessage());
|
||||
// Continue with other SVGs
|
||||
}
|
||||
}
|
||||
|
||||
if (document.getNumberOfPages() == 0) {
|
||||
throw new IOException("No SVG files were successfully added to the PDF");
|
||||
}
|
||||
|
||||
document.save(outputStream);
|
||||
byte[] result = outputStream.toByteArray();
|
||||
log.debug(
|
||||
"Combined SVG to PDF conversion complete, output size: {} bytes",
|
||||
result.length);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private void addSvgAsPage(PDDocument document, byte[] svgBytes) throws IOException {
|
||||
String parser = XMLResourceDescriptor.getXMLParserClassName();
|
||||
SAXSVGDocumentFactory factory = new SAXSVGDocumentFactory(parser);
|
||||
|
||||
SVGDocument svgDoc;
|
||||
try (ByteArrayInputStream inputStream = new ByteArrayInputStream(svgBytes)) {
|
||||
svgDoc = factory.createSVGDocument("file:///input.svg", inputStream);
|
||||
}
|
||||
|
||||
UserAgent userAgent = new UserAgentAdapter();
|
||||
DocumentLoader loader = new DocumentLoader(userAgent);
|
||||
BridgeContext ctx = new BridgeContext(userAgent, loader);
|
||||
ctx.setDynamicState(BridgeContext.DYNAMIC);
|
||||
|
||||
GraphicsNode rootNode = buildGvtWithTimeout(ctx, svgDoc);
|
||||
|
||||
float svgWidth = (float) ctx.getDocumentSize().getWidth();
|
||||
float svgHeight = (float) ctx.getDocumentSize().getHeight();
|
||||
|
||||
if (svgWidth <= 0) svgWidth = DEFAULT_PAGE_WIDTH;
|
||||
if (svgHeight <= 0) svgHeight = DEFAULT_PAGE_HEIGHT;
|
||||
|
||||
// Use SVG dimensions directly for the PDF page
|
||||
PDPage page = new PDPage(new PDRectangle(svgWidth, svgHeight));
|
||||
document.addPage(page);
|
||||
|
||||
PdfBoxGraphics2D pdfGraphics = new PdfBoxGraphics2D(document, svgWidth, svgHeight);
|
||||
try {
|
||||
rootNode.paint(pdfGraphics);
|
||||
} finally {
|
||||
pdfGraphics.dispose();
|
||||
}
|
||||
|
||||
PDFormXObject xform = pdfGraphics.getXFormObject();
|
||||
try (PDPageContentStream contentStream = new PDPageContentStream(document, page)) {
|
||||
contentStream.drawForm(xform);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-56
@@ -4,8 +4,6 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -15,7 +13,6 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -27,7 +24,6 @@ import stirling.software.SPDF.model.PipelineOperation;
|
||||
import stirling.software.SPDF.model.PipelineResult;
|
||||
import stirling.software.SPDF.service.ApiDocService;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PipelineProcessorTest {
|
||||
@@ -38,16 +34,11 @@ class PipelineProcessorTest {
|
||||
|
||||
@Mock ServletContext servletContext;
|
||||
|
||||
@Mock TempFileManager tempFileManager;
|
||||
|
||||
PipelineProcessor pipelineProcessor;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
pipelineProcessor =
|
||||
spy(
|
||||
new PipelineProcessor(
|
||||
apiDocService, userService, servletContext, tempFileManager));
|
||||
pipelineProcessor = spy(new PipelineProcessor(apiDocService, userService, servletContext));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -68,18 +59,12 @@ class PipelineProcessorTest {
|
||||
when(apiDocService.isValidOperation(eq("/api/v1/filter/filter-page-count"), anyMap()))
|
||||
.thenReturn(true);
|
||||
|
||||
// Use a FileSystemResource backed by a temp file to avoid FileNotFoundException
|
||||
Path emptyTemp = Files.createTempFile("empty", ".tmp");
|
||||
Resource emptyResource = new FileSystemResource(emptyTemp.toFile());
|
||||
|
||||
doReturn(new ResponseEntity<>(emptyResource, HttpStatus.OK))
|
||||
doReturn(new ResponseEntity<>(new byte[0], HttpStatus.OK))
|
||||
.when(pipelineProcessor)
|
||||
.sendWebRequest(anyString(), any());
|
||||
|
||||
PipelineResult result = pipelineProcessor.runPipelineAgainstFiles(files, config);
|
||||
|
||||
Files.deleteIfExists(emptyTemp);
|
||||
|
||||
assertTrue(
|
||||
result.isFiltersApplied(),
|
||||
"Filter flag should be true when operation filters file");
|
||||
@@ -87,45 +72,6 @@ class PipelineProcessorTest {
|
||||
assertTrue(result.getOutputFiles().isEmpty(), "Filtered file list should be empty");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPipelineSuccessWithResource() throws Exception {
|
||||
PipelineOperation op = new PipelineOperation();
|
||||
op.setOperation("/api/v1/misc/compress");
|
||||
op.setParameters(Map.of());
|
||||
PipelineConfig config = new PipelineConfig();
|
||||
config.setOperations(List.of(op));
|
||||
|
||||
Resource inputFile = new MyFileByteArrayResource();
|
||||
List<Resource> files = List.of(inputFile);
|
||||
|
||||
Path tempPath = Files.createTempFile("test-output", ".pdf");
|
||||
Files.write(tempPath, "processed_data".getBytes());
|
||||
Resource outputResource =
|
||||
new FileSystemResource(tempPath.toFile()) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return "processed.pdf";
|
||||
}
|
||||
};
|
||||
|
||||
when(apiDocService.isMultiInput(anyString())).thenReturn(false);
|
||||
when(apiDocService.getExtensionTypes(anyBoolean(), anyString())).thenReturn(List.of("pdf"));
|
||||
when(apiDocService.isValidOperation(anyString(), anyMap())).thenReturn(true);
|
||||
|
||||
doReturn(new ResponseEntity<>(outputResource, HttpStatus.OK))
|
||||
.when(pipelineProcessor)
|
||||
.sendWebRequest(anyString(), any());
|
||||
|
||||
PipelineResult result = pipelineProcessor.runPipelineAgainstFiles(files, config);
|
||||
|
||||
verify(pipelineProcessor).sendWebRequest(anyString(), any());
|
||||
|
||||
assertFalse(result.isHasErrors());
|
||||
|
||||
// Clean up
|
||||
Files.deleteIfExists(tempPath);
|
||||
}
|
||||
|
||||
private static class MyFileByteArrayResource extends ByteArrayResource {
|
||||
public MyFileByteArrayResource() {
|
||||
super("data".getBytes());
|
||||
|
||||
Generated
-26
@@ -58,7 +58,6 @@
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"license-report": "^6.8.0",
|
||||
"modern-screenshot": "^4.4.39",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
"peerjs": "^1.5.5",
|
||||
@@ -10539,31 +10538,6 @@
|
||||
"ufo": "^1.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/mlly/node_modules/confbox": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
|
||||
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mlly/node_modules/pkg-types": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
|
||||
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.1.8",
|
||||
"mlly": "^1.7.4",
|
||||
"pathe": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/modern-screenshot": {
|
||||
"version": "4.6.7",
|
||||
"resolved": "https://registry.npmjs.org/modern-screenshot/-/modern-screenshot-4.6.7.tgz",
|
||||
"integrity": "sha512-0GhgI6i6le4AhKzCvLYjwEmsP47kTsX45iT5yuAzsLTi/7i3Rjxe8fbH2VjGJLuyOThwsa0CdQAPd4auoEtsZg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/module-definition": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/module-definition/-/module-definition-6.0.1.tgz",
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.12.2",
|
||||
"globals": "^17.0.0",
|
||||
"modern-screenshot": "^4.4.39",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI لعرض الصور"
|
||||
cbrOptions = "خيارات CBR"
|
||||
cbrOutputOptions = "خيارات تحويل PDF إلى CBR"
|
||||
cbrDpi = "DPI لعرض الصور"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "خيارات تحويل الكتب الإلكترونية إلى PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "تطبيق الصور"
|
||||
name = "صورة"
|
||||
placeholder = "ارفع صورة"
|
||||
label = "ملف صورة"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "تهيئة الصورة"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "Şəkil göstərilməsi üçün DPI"
|
||||
cbrOptions = "CBR seçimləri"
|
||||
cbrOutputOptions = "PDF-dən CBR-ə seçimlər"
|
||||
cbrDpi = "Şəkil göstərilməsi üçün DPI"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "eBook-dan PDF-ə seçimlər"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Şəkilləri tətbiq et"
|
||||
name = "Şəkil"
|
||||
placeholder = "Şəkil yükləyin"
|
||||
label = "Şəkil faylı"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Şəkili tənzimlə"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI за изобразяване на изображение"
|
||||
cbrOptions = "Опции за CBR"
|
||||
cbrOutputOptions = "Опции за PDF към CBR"
|
||||
cbrDpi = "DPI за рендериране на изображения"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Опции за eBook към PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Приложи изображения"
|
||||
name = "Изображение"
|
||||
placeholder = "Качете изображение"
|
||||
label = "Файл с изображение"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Конфигурирай изображението"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "པར་རིས་པར་ལེན་ཆེད་ཀྱི་DPI
|
||||
cbrOptions = "CBR གདམ་ག།"
|
||||
cbrOutputOptions = "PDF ནས་ CBR གདམ་ག།"
|
||||
cbrDpi = "པར་རིས་པར་ལེན་ཆེད་ཀྱི་DPI།"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "གློག་རྡུལ་དེབ་ནས་PDFགདམ་ག།"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "པར་རིས་སྦྱར་བ།"
|
||||
name = "པར་རིས"
|
||||
placeholder = "པར་རིས་ཤིག་བསྒྱུར།"
|
||||
label = "པར་རིས་ཡིག་ཆ།"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "པར་རིས་སྒྲིག་བཀོད་བྱེད་པ།"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI per al renderitzat d'imatges"
|
||||
cbrOptions = "Opcions CBR"
|
||||
cbrOutputOptions = "Opcions de PDF a CBR"
|
||||
cbrDpi = "DPI per al renderitzat d'imatges"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Opcions d'eBook a PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Aplica les imatges"
|
||||
name = "Imatge"
|
||||
placeholder = "Puja una imatge"
|
||||
label = "Fitxer d'imatge"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Configura la imatge"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI pro vykreslení obrázků"
|
||||
cbrOptions = "CBR Options"
|
||||
cbrOutputOptions = "PDF to CBR Options"
|
||||
cbrDpi = "DPI for image rendering"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "eBook to PDF Options"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Použít obrázky"
|
||||
name = "Obrázek"
|
||||
placeholder = "Nahrajte obrázek"
|
||||
label = "Soubor obrázku"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Nastavit obrázek"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI for billedgengivelse"
|
||||
cbrOptions = "CBR-indstillinger"
|
||||
cbrOutputOptions = "PDF til CBR-indstillinger"
|
||||
cbrDpi = "DPI for billedgengivelse"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "e-bog til PDF-indstillinger"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Anvend billeder"
|
||||
name = "Billede"
|
||||
placeholder = "Upload et billede"
|
||||
label = "Billedfil"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Konfigurer billede"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI für Bildrendering"
|
||||
cbrOptions = "CBR-Optionen"
|
||||
cbrOutputOptions = "Optionen: PDF zu CBR"
|
||||
cbrDpi = "DPI für Bildrendering"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Optionen: E-Book zu PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Bilder anwenden"
|
||||
name = "Bild"
|
||||
placeholder = "Ein Bild hochladen"
|
||||
label = "Bilddatei"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Bild konfigurieren"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI για απόδοση εικόνας"
|
||||
cbrOptions = "Επιλογές CBR"
|
||||
cbrOutputOptions = "Επιλογές PDF σε CBR"
|
||||
cbrDpi = "DPI για απόδοση εικόνων"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Επιλογές eBook σε PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Εφαρμογή εικόνων"
|
||||
name = "Εικόνα"
|
||||
placeholder = "Ανεβάστε μια εικόνα"
|
||||
label = "Αρχείο εικόνας"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Ρύθμιση εικόνας"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI for image rendering"
|
||||
cbrOptions = "CBR Options"
|
||||
cbrOutputOptions = "PDF to CBR Options"
|
||||
cbrDpi = "DPI for image rendering"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "eBook to PDF Options"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Apply Images"
|
||||
name = "Image"
|
||||
placeholder = "Upload an image"
|
||||
label = "Image file"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Configure Image"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI para renderizado de imágenes"
|
||||
cbrOptions = "Opciones de CBR"
|
||||
cbrOutputOptions = "Opciones de PDF a CBR"
|
||||
cbrDpi = "DPI para el renderizado de imágenes"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Opciones de eBook a PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Aplicar imágenes"
|
||||
name = "Imagen"
|
||||
placeholder = "Suba una imagen"
|
||||
label = "Archivo de imagen"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Configurar imagen"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "Irudien errendatzeko DPI"
|
||||
cbrOptions = "CBR aukerak"
|
||||
cbrOutputOptions = "PDFtik CBRrako aukerak"
|
||||
cbrDpi = "Irudien errendatzeko DPIa"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "eBooketik PDFra aukerak"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Aplikatu irudiak"
|
||||
name = "Irudia"
|
||||
placeholder = "Igo irudi bat"
|
||||
label = "Irudi fitxategia"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Irudia konfiguratu"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI برای رندر تصویر"
|
||||
cbrOptions = "گزینههای CBR"
|
||||
cbrOutputOptions = "گزینههای PDF به CBR"
|
||||
cbrDpi = "DPI برای رندر تصویر"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "گزینههای eBook به PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "اعمال تصاویر"
|
||||
name = "تصویر"
|
||||
placeholder = "بارگذاری یک تصویر"
|
||||
label = "فایل تصویر"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "پیکربندی تصویر"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI pour le rendu des images"
|
||||
cbrOptions = "Options CBR"
|
||||
cbrOutputOptions = "Options PDF vers CBR"
|
||||
cbrDpi = "DPI pour le rendu des images"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Options eBook vers PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Appliquer les images"
|
||||
name = "Image"
|
||||
placeholder = "Téléverser une image"
|
||||
label = "Fichier image"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Configurer l’image"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI le haghaidh rindreáil íomhá"
|
||||
cbrOptions = "CRoghanna BR"
|
||||
cbrOutputOptions = "Roghanna PDF go CBR"
|
||||
cbrDpi = "DPI le haghaidh rindreáil íomhá"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Roghanna ríomhleabhar go PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Cuir Íomhánna i bhFeidhm"
|
||||
name = "Íomhá"
|
||||
placeholder = "Uaslódáil íomhá"
|
||||
label = "Comhad íomhá"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Cumraigh Íomhá"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "इमेज रेंडरिंग के लिए DPI"
|
||||
cbrOptions = "CBR विकल्प"
|
||||
cbrOutputOptions = "PDF से CBR विकल्प"
|
||||
cbrDpi = "छवि रेंडरिंग के लिए DPI"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "eBook से PDF विकल्प"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "इमेज लागू करें"
|
||||
name = "इमेज"
|
||||
placeholder = "एक इमेज अपलोड करें"
|
||||
label = "इमेज फ़ाइल"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "इमेज कॉन्फ़िगर करें"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI za renderiranje slike"
|
||||
cbrOptions = "CBR opcije"
|
||||
cbrOutputOptions = "Opcije PDF u CBR"
|
||||
cbrDpi = "DPI za renderiranje slike"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Opcije eBook u PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Primijeni slike"
|
||||
name = "Slika"
|
||||
placeholder = "Učitaj sliku"
|
||||
label = "Datoteka slike"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Konfiguriraj sliku"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI a képrendereléshez"
|
||||
cbrOptions = "CBR beállítások"
|
||||
cbrOutputOptions = "PDF → CBR beállítások"
|
||||
cbrDpi = "DPI a képrendereléshez"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "E-könyv → PDF beállítások"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Képek alkalmazása"
|
||||
name = "Kép"
|
||||
placeholder = "Töltsön fel egy képet"
|
||||
label = "Képfájl"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Kép beállítása"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI untuk perenderan gambar"
|
||||
cbrOptions = "Opsi CBR"
|
||||
cbrOutputOptions = "Opsi PDF ke CBR"
|
||||
cbrDpi = "DPI untuk perenderan gambar"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Opsi eBook ke PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Terapkan Gambar"
|
||||
name = "Gambar"
|
||||
placeholder = "Unggah gambar"
|
||||
label = "File gambar"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Konfigurasi Gambar"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI per il rendering delle immagini"
|
||||
cbrOptions = "Opzioni CBR"
|
||||
cbrOutputOptions = "Opzioni da PDF a CBR"
|
||||
cbrDpi = "DPI per il rendering delle immagini"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Opzioni da eBook a PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Applica immagini"
|
||||
name = "Immagine"
|
||||
placeholder = "Carica un'immagine"
|
||||
label = "File immagine"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Configura immagine"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "画像レンダリングの DPI"
|
||||
cbrOptions = "CBR オプション"
|
||||
cbrOutputOptions = "PDF から CBR へのオプション"
|
||||
cbrDpi = "画像レンダリング用 DPI"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "eBook から PDF へのオプション"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "画像を適用"
|
||||
name = "画像"
|
||||
placeholder = "画像をアップロード"
|
||||
label = "画像ファイル"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "画像を設定"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "이미지 렌더링용 DPI"
|
||||
cbrOptions = "CBR 옵션"
|
||||
cbrOutputOptions = "PDF에서 CBR로 옵션"
|
||||
cbrDpi = "이미지 렌더링용 DPI"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "eBook에서 PDF로 옵션"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "이미지 적용"
|
||||
name = "이미지"
|
||||
placeholder = "이미지 업로드"
|
||||
label = "이미지 파일"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "이미지 구성"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "ഇമേജ് റെൻഡറിംഗിനുള്ള DPI"
|
||||
cbrOptions = "CBR ഓപ്ഷനുകൾ"
|
||||
cbrOutputOptions = "PDF-നെ CBR ആക്കാനുള്ള ഓപ്ഷനുകൾ"
|
||||
cbrDpi = "ഇമേജ് റൻഡറിംഗിനുള്ള DPI"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "eBook-നെ PDF ആക്കാനുള്ള ഓപ്ഷനുകൾ"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "ചിത്രങ്ങൾ പ്രയോഗിക്കു
|
||||
name = "ചിത്രം"
|
||||
placeholder = "ഒരു ചിത്രം അപ്ലോഡ് ചെയ്യുക"
|
||||
label = "ചിത്ര ഫയൽ"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "ചിത്രം ക്രമീകരിക്കുക"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI voor weergave van afbeeldingen"
|
||||
cbrOptions = "CBR-opties"
|
||||
cbrOutputOptions = "Opties voor PDF naar CBR"
|
||||
cbrDpi = "DPI voor afbeeldingsrendering"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Opties voor eBook naar PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Afbeeldingen toepassen"
|
||||
name = "Afbeelding"
|
||||
placeholder = "Upload een afbeelding"
|
||||
label = "Afbeeldingsbestand"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Afbeelding configureren"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI for bildegjengivelse"
|
||||
cbrOptions = "CBR-alternativer"
|
||||
cbrOutputOptions = "Alternativer for PDF til CBR"
|
||||
cbrDpi = "DPI for bildegjengivelse"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Alternativer for eBook til PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Bruk bilder"
|
||||
name = "Bilde"
|
||||
placeholder = "Last opp et bilde"
|
||||
label = "Bildefil"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Konfigurer bilde"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI renderowania obrazu"
|
||||
cbrOptions = "Opcje CBR"
|
||||
cbrOutputOptions = "Opcje konwersji PDF do CBR"
|
||||
cbrDpi = "DPI renderowania obrazu"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Opcje konwersji eBook do PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Zastosuj obrazy"
|
||||
name = "Obraz"
|
||||
placeholder = "Prześlij obraz"
|
||||
label = "Plik obrazu"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Skonfiguruj obraz"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI para renderização de imagem"
|
||||
cbrOptions = "Opções de CBR"
|
||||
cbrOutputOptions = "Opções de PDF para CBR"
|
||||
cbrDpi = "DPI para renderização de imagem"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Opções de eBook para PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Aplicar imagens"
|
||||
name = "Imagem"
|
||||
placeholder = "Enviar uma imagem"
|
||||
label = "Arquivo de imagem"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Configurar imagem"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI para renderização de imagem"
|
||||
cbrOptions = "Opções de CBR"
|
||||
cbrOutputOptions = "Opções de PDF para CBR"
|
||||
cbrDpi = "DPI para renderização de imagem"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Opções de eBook para PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Aplicar imagens"
|
||||
name = "Imagem"
|
||||
placeholder = "Carregar uma imagem"
|
||||
label = "Ficheiro de imagem"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Configurar imagem"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI pentru randarea imaginilor"
|
||||
cbrOptions = "Opțiuni CBR"
|
||||
cbrOutputOptions = "Opțiuni PDF către CBR"
|
||||
cbrDpi = "DPI pentru randarea imaginilor"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Opțiuni eBook către PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Aplică imagini"
|
||||
name = "Imagine"
|
||||
placeholder = "Încarcă o imagine"
|
||||
label = "Fișier imagine"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Configurează imaginea"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI для отрисовки изображений"
|
||||
cbrOptions = "Параметры CBR"
|
||||
cbrOutputOptions = "Параметры PDF в CBR"
|
||||
cbrDpi = "DPI для отрисовки изображений"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Параметры eBook в PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Применить изображения"
|
||||
name = "Изображение"
|
||||
placeholder = "Загрузите изображение"
|
||||
label = "Файл изображения"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Настроить изображение"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI pre vykresľovanie obrázkov"
|
||||
cbrOptions = "Možnosti CBR"
|
||||
cbrOutputOptions = "Možnosti PDF na CBR"
|
||||
cbrDpi = "DPI pre vykresľovanie obrázkov"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Možnosti eBook na PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Použiť obrázky"
|
||||
name = "Obrázok"
|
||||
placeholder = "Nahrajte obrázok"
|
||||
label = "Súbor obrázka"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Nastaviť obrázok"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI za upodabljanje slik"
|
||||
cbrOptions = "Možnosti CBR"
|
||||
cbrOutputOptions = "Možnosti PDF v CBR"
|
||||
cbrDpi = "DPI za upodabljanje slik"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Možnosti eBook v PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Uporabi slike"
|
||||
name = "Slika"
|
||||
placeholder = "Naložite sliko"
|
||||
label = "Slikovna datoteka"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Nastavi sliko"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI za renderovanje slike"
|
||||
cbrOptions = "CBR opcije"
|
||||
cbrOutputOptions = "Opcije za PDF u CBR"
|
||||
cbrDpi = "DPI za renderovanje slike"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Opcije za eBook u PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Primeni slike"
|
||||
name = "Slika"
|
||||
placeholder = "Otpremite sliku"
|
||||
label = "Datoteka slike"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Podesi sliku"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI för bildrendering"
|
||||
cbrOptions = "CBR-alternativ"
|
||||
cbrOutputOptions = "Alternativ för PDF till CBR"
|
||||
cbrDpi = "DPI för bildrendering"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Alternativ för e-bok till PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Använd bilder"
|
||||
name = "Bild"
|
||||
placeholder = "Ladda upp en bild"
|
||||
label = "Bildfil"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Konfigurera bild"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI สำหรับการเรนเดอร์ภาพ"
|
||||
cbrOptions = "ตัวเลือก CBR"
|
||||
cbrOutputOptions = "ตัวเลือก PDF เป็น CBR"
|
||||
cbrDpi = "DPI สำหรับการเรนเดอร์ภาพ"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "ตัวเลือก eBook เป็น PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "ใช้รูปภาพ"
|
||||
name = "รูปภาพ"
|
||||
placeholder = "อัปโหลดรูปภาพ"
|
||||
label = "ไฟล์รูปภาพ"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "ตั้งค่ารูปภาพ"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "Görüntü oluşturma için DPI"
|
||||
cbrOptions = "CBR Seçenekleri"
|
||||
cbrOutputOptions = "PDF'den CBR'ye Dönüştürme Seçenekleri"
|
||||
cbrDpi = "Görüntü oluşturma için DPI"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "eBook'tan PDF'ye Dönüştürme Seçenekleri"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Resimleri Uygula"
|
||||
name = "Resim"
|
||||
placeholder = "Bir resim yükleyin"
|
||||
label = "Resim dosyası"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Resmi Yapılandır"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI для рендерингу зображень"
|
||||
cbrOptions = "Параметри CBR"
|
||||
cbrOutputOptions = "Параметри перетворення PDF у CBR"
|
||||
cbrDpi = "DPI для рендерингу зображень"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Параметри перетворення електронної книги у PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Застосувати зображення"
|
||||
name = "Зображення"
|
||||
placeholder = "Завантажте зображення"
|
||||
label = "Файл зображення"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Налаштувати зображення"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "DPI cho kết xuất ảnh"
|
||||
cbrOptions = "Tùy chọn CBR"
|
||||
cbrOutputOptions = "Tùy chọn PDF sang CBR"
|
||||
cbrDpi = "DPI cho kết xuất hình ảnh"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "Tùy chọn eBook sang PDF"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "Áp dụng ảnh"
|
||||
name = "Ảnh"
|
||||
placeholder = "Tải lên một ảnh"
|
||||
label = "Tệp ảnh"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "Cấu hình ảnh"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "图像渲染的 DPI"
|
||||
cbrOptions = "CBR 选项"
|
||||
cbrOutputOptions = "PDF 转 CBR 选项"
|
||||
cbrDpi = "图像渲染的 DPI"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "电子书转 PDF 选项"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "应用图像"
|
||||
name = "图像"
|
||||
placeholder = "上传图像"
|
||||
label = "图像文件"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "配置图像"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "图像渲染的 DPI"
|
||||
cbrOptions = "CBR 选项"
|
||||
cbrOutputOptions = "PDF 转 CBR 选项"
|
||||
cbrDpi = "用于图像渲染的 DPI"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "电子书转 PDF 选项"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "应用图片"
|
||||
name = "图片"
|
||||
placeholder = "上传图片"
|
||||
label = "图片文件"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "配置图片"
|
||||
|
||||
@@ -1295,10 +1295,6 @@ cbzDpi = "影像轉繪的 DPI"
|
||||
cbrOptions = "CBR 選項"
|
||||
cbrOutputOptions = "PDF 轉 CBR 選項"
|
||||
cbrDpi = "影像渲染的 DPI"
|
||||
svgPdfOptions = "SVG to PDF Options"
|
||||
combineSvgs = "Combine SVGs into single PDF"
|
||||
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
|
||||
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "電子書轉 PDF 選項"
|
||||
@@ -1400,7 +1396,6 @@ applySignatures = "套用圖片"
|
||||
name = "圖片"
|
||||
placeholder = "上傳一張圖片"
|
||||
label = "圖片檔"
|
||||
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
|
||||
|
||||
[addImage.steps]
|
||||
configure = "設定圖片"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Stirling-PDF",
|
||||
"version": "2.3.0",
|
||||
"version": "2.2.1",
|
||||
"identifier": "stirling.pdf.dev",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -77,26 +77,15 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
setCurrentFile(file);
|
||||
onImageChange(file);
|
||||
|
||||
let dataUrlToProcess: string;
|
||||
|
||||
// Check if file is SVG
|
||||
const isSvg = file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg');
|
||||
|
||||
if (isSvg) {
|
||||
// For SVG, convert to PNG so it can be embedded in PDF
|
||||
dataUrlToProcess = await convertSvgToPng(file);
|
||||
} else {
|
||||
// For other images, read as data URL directly
|
||||
dataUrlToProcess = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target?.result as string);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
const originalDataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target?.result as string);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
setOriginalImageData(dataUrlToProcess);
|
||||
await processImage(dataUrlToProcess, removeBackground);
|
||||
setOriginalImageData(originalDataUrl);
|
||||
await processImage(file, removeBackground);
|
||||
} catch (error) {
|
||||
console.error('Error processing image file:', error);
|
||||
}
|
||||
@@ -109,115 +98,6 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to convert SVG to PNG
|
||||
const convertSvgToPng = async (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const svgText = e.target?.result as string;
|
||||
|
||||
// Parse SVG to get dimensions
|
||||
const parser = new DOMParser();
|
||||
const svgDoc = parser.parseFromString(svgText, 'image/svg+xml');
|
||||
const svgElement = svgDoc.documentElement;
|
||||
|
||||
// Get SVG dimensions
|
||||
let width = 800; // Default width
|
||||
let height = 600; // Default height
|
||||
|
||||
if (svgElement.hasAttribute('width') && svgElement.hasAttribute('height')) {
|
||||
width = parseFloat(svgElement.getAttribute('width') || '800');
|
||||
height = parseFloat(svgElement.getAttribute('height') || '600');
|
||||
} else if (svgElement.hasAttribute('viewBox')) {
|
||||
const viewBox = svgElement.getAttribute('viewBox')?.split(/\s+|,/);
|
||||
if (viewBox && viewBox.length === 4) {
|
||||
width = parseFloat(viewBox[2]);
|
||||
height = parseFloat(viewBox[3]);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure reasonable dimensions
|
||||
if (width === 0 || height === 0 || !isFinite(width) || !isFinite(height)) {
|
||||
width = 800;
|
||||
height = 600;
|
||||
}
|
||||
|
||||
// Scale large SVGs down
|
||||
const maxDimension = 2048;
|
||||
if (width > maxDimension || height > maxDimension) {
|
||||
const scale = Math.min(maxDimension / width, maxDimension / height);
|
||||
width *= scale;
|
||||
height *= scale;
|
||||
}
|
||||
|
||||
console.log('Converting SVG to PNG:', { width, height });
|
||||
|
||||
// Create an image element to render SVG
|
||||
const img = new Image();
|
||||
const blob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
// Use computed dimensions or image natural dimensions
|
||||
const finalWidth = img.naturalWidth || img.width || width;
|
||||
const finalHeight = img.naturalHeight || img.height || height;
|
||||
|
||||
console.log('Image loaded:', { naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight, finalWidth, finalHeight });
|
||||
|
||||
// Create canvas to convert to PNG
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = finalWidth;
|
||||
canvas.height = finalHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Failed to get canvas context'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fill with white background (optional, for transparency support)
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillRect(0, 0, finalWidth, finalHeight);
|
||||
|
||||
// Draw SVG
|
||||
ctx.drawImage(img, 0, 0, finalWidth, finalHeight);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// Convert canvas to PNG data URL
|
||||
const pngDataUrl = canvas.toDataURL('image/png');
|
||||
console.log('SVG converted to PNG successfully');
|
||||
resolve(pngDataUrl);
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(url);
|
||||
console.error('Error during canvas rendering:', error);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = (error) => {
|
||||
URL.revokeObjectURL(url);
|
||||
console.error('Failed to load SVG image:', error);
|
||||
reject(new Error('Failed to load SVG image'));
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
} catch (error) {
|
||||
console.error('Error parsing SVG:', error);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
console.error('Error reading file:', reader.error);
|
||||
reject(reader.error);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
};
|
||||
|
||||
const handleBackgroundRemovalChange = async (checked: boolean) => {
|
||||
if (isProcessing) return; // Prevent race conditions
|
||||
setRemoveBackground(checked);
|
||||
|
||||
@@ -59,7 +59,7 @@ export const ImageTool: React.FC<ImageToolProps> = ({
|
||||
disabled={disabled}
|
||||
label="Upload Image"
|
||||
placeholder="Select image file"
|
||||
hint="Upload a PNG, JPG, SVG, or other image file to place on the PDF. SVG files will be converted to PNG for compatibility."
|
||||
hint="Upload a PNG, JPG, or other image file to place on the PDF"
|
||||
/>
|
||||
</Stack>
|
||||
</BaseAnnotationTool>
|
||||
|
||||
@@ -399,4 +399,3 @@
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from '@app/contexts/FileContext';
|
||||
import { useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { zipFileService } from '@app/services/zipFileService';
|
||||
import { detectFileExtension } from '@app/utils/fileUtils';
|
||||
import FileEditorThumbnail from '@app/components/fileEditor/FileEditorThumbnail';
|
||||
@@ -19,15 +18,15 @@ import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
|
||||
|
||||
interface FileEditorProps {
|
||||
onOpenViewer?: (fileId: FileId, sourceRect?: DOMRect) => void;
|
||||
onOpenPageEditor?: () => void;
|
||||
onMergeFiles?: (files: StirlingFile[]) => void;
|
||||
toolMode?: boolean;
|
||||
supportedExtensions?: string[];
|
||||
}
|
||||
|
||||
const FileEditor = ({
|
||||
toolMode = false,
|
||||
supportedExtensions = ["pdf"],
|
||||
onOpenViewer
|
||||
supportedExtensions = ["pdf"]
|
||||
}: FileEditorProps) => {
|
||||
|
||||
// Utility function to check if a file extension is supported
|
||||
@@ -55,9 +54,6 @@ const FileEditor = ({
|
||||
// Get file selection context
|
||||
const { setSelectedFiles } = useFileSelection();
|
||||
|
||||
// Get viewer context for active file index
|
||||
const { setActiveFileIndex } = useViewer();
|
||||
|
||||
const [_status, _setStatus] = useState<string | null>(null);
|
||||
const [_error, _setError] = useState<string | null>(null);
|
||||
|
||||
@@ -70,7 +66,6 @@ const FileEditor = ({
|
||||
}, []);
|
||||
const [selectionMode, setSelectionMode] = useState(toolMode);
|
||||
|
||||
|
||||
// Current tool (for enforcing maxFiles limits)
|
||||
const { selectedTool } = useToolWorkflow();
|
||||
|
||||
@@ -333,28 +328,14 @@ const FileEditor = ({
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, fileActions, removeFiles]);
|
||||
|
||||
const handleViewFile = useCallback(async (fileId: FileId, sourceElement?: HTMLElement) => {
|
||||
const handleViewFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
if (record) {
|
||||
// Find the index of the clicked file
|
||||
const fileIndex = activeStirlingFileStubs.findIndex(r => r.id === fileId);
|
||||
|
||||
// Set the file as selected and active
|
||||
// Set the file as selected in context and switch to viewer for preview
|
||||
setSelectedFiles([fileId]);
|
||||
if (fileIndex !== -1) {
|
||||
setActiveFileIndex(fileIndex);
|
||||
}
|
||||
|
||||
const sourceRect = sourceElement?.getBoundingClientRect();
|
||||
|
||||
// Switch to viewer - pass fileId and sourceRect (parent handles fallbacks)
|
||||
if (onOpenViewer) {
|
||||
onOpenViewer(fileId, sourceRect);
|
||||
} else {
|
||||
navActions.setWorkbench('viewer');
|
||||
}
|
||||
navActions.setWorkbench('viewer');
|
||||
}
|
||||
}, [activeStirlingFileStubs, setSelectedFiles, setActiveFileIndex, navActions, onOpenViewer]);
|
||||
}, [activeStirlingFileStubs, setSelectedFiles, navActions.setWorkbench]);
|
||||
|
||||
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
@@ -383,12 +364,7 @@ const FileEditor = ({
|
||||
activateOnClick={false}
|
||||
activateOnDrag={true}
|
||||
>
|
||||
<Box
|
||||
pos="relative"
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<Box pos="relative" style={{ overflow: 'auto' }}>
|
||||
<LoadingOverlay visible={state.ui.isProcessing} />
|
||||
|
||||
<Box p="md">
|
||||
|
||||
@@ -35,7 +35,7 @@ interface FileEditorThumbnailProps {
|
||||
selectionMode: boolean;
|
||||
onToggleFile: (fileId: FileId) => void;
|
||||
onCloseFile: (fileId: FileId) => void;
|
||||
onViewFile: (fileId: FileId, sourceElement?: HTMLElement) => void;
|
||||
onViewFile: (fileId: FileId) => void;
|
||||
_onSetStatus: (status: string) => void;
|
||||
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
||||
onDownloadFile: (fileId: FileId) => void;
|
||||
@@ -92,7 +92,6 @@ const FileEditorThumbnail = ({
|
||||
const isEncrypted = Boolean(file.processedFile?.isEncrypted);
|
||||
|
||||
const handleRef = useRef<HTMLSpanElement | null>(null);
|
||||
const thumbnailRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
// ---- Selection ----
|
||||
const isSelected = selectedFiles.includes(file.id);
|
||||
@@ -204,7 +203,7 @@ const FileEditorThumbnail = ({
|
||||
label: t('openInViewer', 'Open in Viewer'),
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
onViewFile(file.id, thumbnailRef.current || undefined);
|
||||
onViewFile(file.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -254,7 +253,7 @@ const FileEditorThumbnail = ({
|
||||
|
||||
const handleCardDoubleClick = () => {
|
||||
if (!isSupported) return;
|
||||
onViewFile(file.id, thumbnailRef.current || undefined);
|
||||
onViewFile(file.id);
|
||||
};
|
||||
|
||||
// ---- Style helpers ----
|
||||
@@ -393,7 +392,6 @@ const FileEditorThumbnail = ({
|
||||
{file.thumbnailUrl ? (
|
||||
<PrivateContent>
|
||||
<img
|
||||
ref={thumbnailRef}
|
||||
src={file.thumbnailUrl}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
@@ -414,7 +412,6 @@ const FileEditorThumbnail = ({
|
||||
display: 'block',
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
justifySelf: 'center',
|
||||
alignSelf: 'start'
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -19,21 +19,3 @@
|
||||
.workbenchScrollable::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--mantine-color-gray-5);
|
||||
}
|
||||
|
||||
@keyframes :global(fadeIn) {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes :global(fadeOut) {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
@@ -8,11 +8,6 @@ import { useNavigationState, useNavigationActions, useNavigationGuard } from '@a
|
||||
import { isBaseWorkbench } from '@app/types/workbench';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import type { FileId } from '@app/types/fileContext';
|
||||
import { VIEWER_TRANSITION } from '@app/constants/animations';
|
||||
import { captureElementScreenshot } from '@app/utils/screenshot';
|
||||
import { useViewerTransition } from '@app/hooks/useViewerTransition';
|
||||
import { usePageEditorTransition } from '@app/hooks/usePageEditorTransition';
|
||||
import styles from '@app/components/layout/Workbench.module.css';
|
||||
|
||||
import TopControls from '@app/components/shared/TopControls';
|
||||
@@ -23,10 +18,6 @@ import Viewer from '@app/components/viewer/Viewer';
|
||||
import LandingPage from '@app/components/shared/LandingPage';
|
||||
import Footer from '@app/components/shared/Footer';
|
||||
import DismissAllErrorsButton from '@app/components/shared/DismissAllErrorsButton';
|
||||
import { ViewerZoomTransition } from '@app/components/viewer/ViewerZoomTransition';
|
||||
import { PageEditorSpreadTransition } from '@app/components/pageEditor/PageEditorSpreadTransition';
|
||||
|
||||
const MAX_PAGE_EDITOR_SCREENSHOT_PAGES = 24;
|
||||
|
||||
// No props needed - component uses contexts directly
|
||||
export default function Workbench() {
|
||||
@@ -35,12 +26,10 @@ export default function Workbench() {
|
||||
|
||||
// Use context-based hooks to eliminate all prop drilling
|
||||
const { selectors } = useFileState();
|
||||
const { workbench: currentView, viewerTransition } = useNavigationState();
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const setCurrentView = navActions.setWorkbench;
|
||||
const activeFiles = selectors.getFiles();
|
||||
|
||||
// Ref for capturing screenshot during TopControls transitions
|
||||
const mainContentRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
previewFile,
|
||||
pageEditorFunctions,
|
||||
@@ -63,8 +52,6 @@ export default function Workbench() {
|
||||
|
||||
// Get active file index from ViewerContext
|
||||
const { activeFileIndex, setActiveFileIndex } = useViewer();
|
||||
const activeFileId = activeFiles[activeFileIndex]?.fileId;
|
||||
const lastViewerScreenshotRef = useRef<string | null>(null);
|
||||
|
||||
// Get navigation guard for unsaved changes check when switching files
|
||||
const { requestNavigation } = useNavigationGuard();
|
||||
@@ -95,196 +82,11 @@ export default function Workbench() {
|
||||
handleToolSelect('convert');
|
||||
sessionStorage.removeItem('previousMode');
|
||||
} else {
|
||||
navActions.setWorkbench('fileEditor');
|
||||
setCurrentView('fileEditor');
|
||||
}
|
||||
};
|
||||
|
||||
const buildPageEditorFilter = useCallback((root: HTMLElement) => {
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const margin = 100;
|
||||
const isRectVisible = (rect: DOMRect) =>
|
||||
rect.bottom >= rootRect.top - margin &&
|
||||
rect.right >= rootRect.left - margin &&
|
||||
rect.top <= rootRect.bottom + margin &&
|
||||
rect.left <= rootRect.right + margin;
|
||||
|
||||
const pageElements = Array.from(root.querySelectorAll('[data-page-id]')) as HTMLElement[];
|
||||
const visiblePages = pageElements.filter((page) => isRectVisible(page.getBoundingClientRect()));
|
||||
const allowedPages = new Set(visiblePages.slice(0, MAX_PAGE_EDITOR_SCREENSHOT_PAGES));
|
||||
|
||||
return (node: Node) => {
|
||||
if (node === root) return true;
|
||||
if (!(node instanceof Element)) return true;
|
||||
|
||||
const pageElement = node.closest('[data-page-id]') as HTMLElement | null;
|
||||
if (pageElement) {
|
||||
return allowedPages.has(pageElement);
|
||||
}
|
||||
|
||||
return isRectVisible(node.getBoundingClientRect());
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Capture screenshot helper for TopControls transitions
|
||||
const captureMainContentScreenshot = useCallback(async (): Promise<string | null> => {
|
||||
const root = mainContentRef.current;
|
||||
if (!root) return null;
|
||||
|
||||
if (currentView === 'pageEditor') {
|
||||
const rect = root.getBoundingClientRect();
|
||||
const filter = buildPageEditorFilter(root);
|
||||
return captureElementScreenshot(root, {
|
||||
filter,
|
||||
width: Math.max(1, Math.round(rect.width)),
|
||||
height: Math.max(1, Math.round(rect.height)),
|
||||
restoreScrollPosition: false,
|
||||
});
|
||||
}
|
||||
|
||||
return captureElementScreenshot(root);
|
||||
}, [currentView, buildPageEditorFilter]);
|
||||
|
||||
const capturePageEditorScreenshot = useCallback(async (): Promise<string | null> => {
|
||||
const root = mainContentRef.current;
|
||||
if (!root) return null;
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const filter = buildPageEditorFilter(root);
|
||||
return captureElementScreenshot(root, {
|
||||
filter,
|
||||
width: Math.max(1, Math.round(rootRect.width)),
|
||||
height: Math.max(1, Math.round(rootRect.height)),
|
||||
restoreScrollPosition: false,
|
||||
});
|
||||
}, [buildPageEditorFilter]);
|
||||
|
||||
const getMainContentRect = useCallback((): DOMRect | null => {
|
||||
const root = mainContentRef.current;
|
||||
return root ? root.getBoundingClientRect() : null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const currentUrl = viewerTransition.editorScreenshotUrl;
|
||||
const previousUrl = lastViewerScreenshotRef.current;
|
||||
|
||||
if (currentUrl && currentUrl !== previousUrl) {
|
||||
if (previousUrl?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previousUrl);
|
||||
}
|
||||
lastViewerScreenshotRef.current = currentUrl;
|
||||
}
|
||||
|
||||
if (!viewerTransition.isAnimating && lastViewerScreenshotRef.current) {
|
||||
const urlToRevoke = lastViewerScreenshotRef.current;
|
||||
if (urlToRevoke.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(urlToRevoke);
|
||||
}
|
||||
lastViewerScreenshotRef.current = null;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (lastViewerScreenshotRef.current) {
|
||||
const urlToRevoke = lastViewerScreenshotRef.current;
|
||||
if (urlToRevoke.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(urlToRevoke);
|
||||
}
|
||||
lastViewerScreenshotRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [viewerTransition.editorScreenshotUrl, viewerTransition.isAnimating]);
|
||||
|
||||
// Get transition handlers
|
||||
const { handleEntryTransition, handleExitTransition } = useViewerTransition({
|
||||
activeFileIndex,
|
||||
currentView,
|
||||
captureScreenshot: captureMainContentScreenshot,
|
||||
getScreenshotRect: getMainContentRect,
|
||||
});
|
||||
|
||||
// Get page editor transition handlers
|
||||
const { handleEntryTransition: handlePageEditorEntry, handleExitTransition: handlePageEditorExit } = usePageEditorTransition({
|
||||
currentView,
|
||||
captureScreenshot: capturePageEditorScreenshot,
|
||||
activeFileId,
|
||||
getScreenshotRect: getMainContentRect,
|
||||
});
|
||||
|
||||
// Wrapper for setCurrentView that adds transition when switching to/from viewer
|
||||
const setCurrentView = useCallback(async (view: typeof currentView, fileId?: FileId, sourceRect?: DOMRect) => {
|
||||
// Handle entry transition (fileEditor/pageEditor → viewer)
|
||||
if (view === 'viewer' && (currentView === 'fileEditor' || currentView === 'pageEditor')) {
|
||||
await handleEntryTransition(fileId, sourceRect);
|
||||
}
|
||||
|
||||
// Handle exit transition (viewer → fileEditor/pageEditor)
|
||||
if ((view === 'fileEditor' || view === 'pageEditor') && currentView === 'viewer') {
|
||||
handleExitTransition(view);
|
||||
}
|
||||
|
||||
// Handle page editor entry (fileEditor → pageEditor)
|
||||
if (view === 'pageEditor' && (currentView === 'fileEditor' || currentView === 'viewer')) {
|
||||
await handlePageEditorEntry(currentView === 'viewer' ? { sourceFileId: activeFileId } : undefined);
|
||||
}
|
||||
|
||||
// Handle page editor exit (pageEditor → fileEditor)
|
||||
if (view === 'fileEditor' && currentView === 'pageEditor') {
|
||||
handlePageEditorExit();
|
||||
}
|
||||
|
||||
navActions.setWorkbench(view);
|
||||
}, [currentView, navActions, handleEntryTransition, handleExitTransition, handlePageEditorEntry, handlePageEditorExit, activeFileId]);
|
||||
|
||||
const renderMainContent = () => {
|
||||
// During viewer transition with screenshot, show screenshot overlay
|
||||
if (viewerTransition.isAnimating && viewerTransition.editorScreenshotUrl) {
|
||||
const viewerContent = (
|
||||
<Viewer
|
||||
sidebarsVisible={sidebarsVisible}
|
||||
setSidebarsVisible={setSidebarsVisible}
|
||||
previewFile={previewFile}
|
||||
onClose={handlePreviewClose}
|
||||
activeFileIndex={activeFileIndex}
|
||||
setActiveFileIndex={setActiveFileIndex}
|
||||
/>
|
||||
);
|
||||
|
||||
// Screenshot fades out when zoom starts
|
||||
const screenshotRect = viewerTransition.editorScreenshotRect;
|
||||
const screenshotOverlay = (
|
||||
<div
|
||||
style={{
|
||||
position: screenshotRect ? 'fixed' : 'absolute',
|
||||
top: screenshotRect ? `${screenshotRect.top}px` : 0,
|
||||
left: screenshotRect ? `${screenshotRect.left}px` : 0,
|
||||
width: screenshotRect ? `${screenshotRect.width}px` : window.innerWidth,
|
||||
height: screenshotRect ? `${screenshotRect.height}px` : window.innerHeight,
|
||||
opacity: viewerTransition.isZooming ? 0 : 1,
|
||||
transition: viewerTransition.isZooming
|
||||
? `opacity ${VIEWER_TRANSITION.SCREENSHOT_FADE_DURATION}ms ease-out`
|
||||
: 'none',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={viewerTransition.editorScreenshotUrl}
|
||||
alt="Loading..."
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'fill',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{viewerContent}
|
||||
{screenshotOverlay}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Check for custom workbench views first
|
||||
if (!isBaseWorkbench(currentView)) {
|
||||
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
|
||||
@@ -313,9 +115,15 @@ export default function Workbench() {
|
||||
<FileEditor
|
||||
toolMode={!!selectedToolId}
|
||||
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
|
||||
onOpenViewer={(fileId, sourceRect) => {
|
||||
setCurrentView("viewer", fileId, sourceRect);
|
||||
}}
|
||||
{...(!selectedToolId && {
|
||||
onOpenPageEditor: () => {
|
||||
setCurrentView("pageEditor");
|
||||
},
|
||||
onMergeFiles: (filesToMerge) => {
|
||||
addFiles(filesToMerge);
|
||||
setCurrentView("viewer");
|
||||
}
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -399,31 +207,22 @@ export default function Workbench() {
|
||||
|
||||
{/* Main content area */}
|
||||
<Box
|
||||
ref={mainContentRef}
|
||||
className={`flex-1 min-h-0 relative z-10 ${styles.workbenchScrollable}`}
|
||||
style={{
|
||||
transition: 'opacity 0.15s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{renderMainContent()}
|
||||
{renderMainContent()}
|
||||
</Box>
|
||||
|
||||
{/* Viewer Zoom Transition Overlay */}
|
||||
<ViewerZoomTransition />
|
||||
|
||||
{/* Page Editor Spread Transition Overlay */}
|
||||
<PageEditorSpreadTransition />
|
||||
|
||||
<Box style={{ position: 'relative', zIndex: 100 }}>
|
||||
<Footer
|
||||
analyticsEnabled={config?.enableAnalytics === true}
|
||||
termsAndConditions={config?.termsAndConditions}
|
||||
privacyPolicy={config?.privacyPolicy}
|
||||
cookiePolicy={config?.cookiePolicy}
|
||||
impressum={config?.impressum}
|
||||
accessibilityStatement={config?.accessibilityStatement}
|
||||
/>
|
||||
</Box>
|
||||
<Footer
|
||||
analyticsEnabled={config?.enableAnalytics === true}
|
||||
termsAndConditions={config?.termsAndConditions}
|
||||
privacyPolicy={config?.privacyPolicy}
|
||||
cookiePolicy={config?.cookiePolicy}
|
||||
impressum={config?.impressum}
|
||||
accessibilityStatement={config?.accessibilityStatement}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,17 +60,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pageAppear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Action styles */
|
||||
.actionRow:hover {
|
||||
background: var(--hover-bg);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo, useLayoutEffect } from "react";
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
||||
import { useNavigationGuard, useNavigationState } from "@app/contexts/NavigationContext";
|
||||
import { useNavigationGuard } from "@app/contexts/NavigationContext";
|
||||
import { usePageEditor } from "@app/contexts/PageEditorContext";
|
||||
import { PageEditorFunctions } from "@app/types/pageEditor";
|
||||
// Thumbnail generation is now handled by individual PageThumbnail components
|
||||
@@ -12,7 +12,6 @@ import SkeletonLoader from '@app/components/shared/SkeletonLoader';
|
||||
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
|
||||
import { FileId } from "@app/types/file";
|
||||
import { GRID_CONSTANTS } from '@app/components/pageEditor/constants';
|
||||
import { PAGE_EDITOR_TRANSITION } from '@app/constants/animations';
|
||||
import { useInitialPageDocument } from '@app/components/pageEditor/hooks/useInitialPageDocument';
|
||||
import { usePageDocument } from '@app/components/pageEditor/hooks/usePageDocument';
|
||||
import { usePageEditorState } from '@app/components/pageEditor/hooks/usePageEditorState';
|
||||
@@ -39,7 +38,6 @@ const PageEditor = ({
|
||||
|
||||
// Navigation guard for unsaved changes
|
||||
const { setHasUnsavedChanges } = useNavigationGuard();
|
||||
const { pageEditorTransition } = useNavigationState();
|
||||
|
||||
// Get PageEditor coordination functions
|
||||
const { updateFileOrderFromPages, fileOrder, reorderedPages, clearReorderedPages, updateCurrentPages } = usePageEditor();
|
||||
@@ -79,7 +77,6 @@ const PageEditor = ({
|
||||
|
||||
const fileObjectsRef = useRef(new Map<FileId, any>());
|
||||
const gridItemRefsRef = useRef<React.MutableRefObject<Map<string, HTMLDivElement>> | null>(null);
|
||||
const burstAnimatedPagesRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const pageEditorFiles = useMemo(() => {
|
||||
const cache = fileObjectsRef.current;
|
||||
@@ -341,146 +338,8 @@ const PageEditor = ({
|
||||
};
|
||||
}, [isContainerHovered, zoomIn, zoomOut]);
|
||||
|
||||
// Progressive page loading for smooth animation
|
||||
const [visiblePageCount, setVisiblePageCount] = useState(1);
|
||||
const [firstPageVisible, setFirstPageVisible] = useState(false);
|
||||
const [animatingPages, setAnimatingPages] = useState(false);
|
||||
const allPages = displayDocument?.pages || [];
|
||||
|
||||
// Animation sequence coordination
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let fadeDelay: number | null = null;
|
||||
let remainingDelay: number | null = null;
|
||||
let glideListener: (() => void) | null = null;
|
||||
|
||||
if (allPages.length <= 1) {
|
||||
setFirstPageVisible(true);
|
||||
setVisiblePageCount(allPages.length);
|
||||
return;
|
||||
}
|
||||
|
||||
const waitForGlideCompletion = () => new Promise<void>((resolve) => {
|
||||
if (!pageEditorTransition?.isAnimating) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
let resolved = false;
|
||||
|
||||
glideListener = () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
resolve();
|
||||
};
|
||||
|
||||
window.addEventListener(PAGE_EDITOR_TRANSITION.GLIDE_COMPLETE_EVENT, glideListener, { once: true });
|
||||
|
||||
const tick = () => {
|
||||
if (resolved) return;
|
||||
if (cancelled) {
|
||||
resolved = true;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
|
||||
const runAnimation = async () => {
|
||||
await waitForGlideCompletion();
|
||||
if (cancelled) return;
|
||||
|
||||
// Phase 2: Fade in first page (300ms)
|
||||
setFirstPageVisible(true);
|
||||
|
||||
fadeDelay = window.setTimeout(() => {
|
||||
// Phase 3: Show first 20 pages for burst (or all if < 20)
|
||||
const burstPageCount = Math.min(20, allPages.length);
|
||||
setVisiblePageCount(burstPageCount);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setAnimatingPages(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Phase 4: Load remaining pages without animation after burst completes
|
||||
if (allPages.length > 20) {
|
||||
remainingDelay = window.setTimeout(() => {
|
||||
setVisiblePageCount(allPages.length);
|
||||
}, 600); // After burst animation completes
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
runAnimation();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (glideListener) {
|
||||
window.removeEventListener(PAGE_EDITOR_TRANSITION.GLIDE_COMPLETE_EVENT, glideListener);
|
||||
}
|
||||
if (fadeDelay !== null) clearTimeout(fadeDelay);
|
||||
if (remainingDelay !== null) clearTimeout(remainingDelay);
|
||||
};
|
||||
}, [allPages.length, pageEditorTransition?.isAnimating]);
|
||||
|
||||
// Reset on document change
|
||||
useEffect(() => {
|
||||
setVisiblePageCount(1);
|
||||
setFirstPageVisible(false);
|
||||
setAnimatingPages(false);
|
||||
burstAnimatedPagesRef.current.clear();
|
||||
}, [allPages.length]);
|
||||
|
||||
const displayedPages = allPages.slice(0, visiblePageCount);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!animatingPages) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstPageElement = document.querySelector('[data-page-number="1"]') as HTMLElement | null;
|
||||
if (!firstPageElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstRect = firstPageElement.getBoundingClientRect();
|
||||
const burstLimit = Math.min(20, allPages.length);
|
||||
const pagesToAnimate = allPages.slice(1, Math.min(visiblePageCount, burstLimit));
|
||||
|
||||
pagesToAnimate.forEach((page) => {
|
||||
if (burstAnimatedPagesRef.current.has(page.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPageElement = document.querySelector(`[data-page-id="${page.id}"]`) as HTMLElement | null;
|
||||
if (!currentPageElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRect = currentPageElement.getBoundingClientRect();
|
||||
const deltaX = firstRect.left - currentRect.left;
|
||||
const deltaY = firstRect.top - currentRect.top;
|
||||
const scaleX = firstRect.width / currentRect.width;
|
||||
const scaleY = firstRect.height / currentRect.height;
|
||||
|
||||
currentPageElement.style.transform = `translate(${deltaX}px, ${deltaY}px) scale(${scaleX}, ${scaleY})`;
|
||||
currentPageElement.style.transformOrigin = 'top left';
|
||||
currentPageElement.style.opacity = '1';
|
||||
currentPageElement.style.transition = 'none';
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
currentPageElement.style.transform = 'translate(0, 0) scale(1)';
|
||||
currentPageElement.style.transition = 'transform 600ms cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 600ms cubic-bezier(0.25, 0.46, 0.45, 0.94)';
|
||||
});
|
||||
|
||||
burstAnimatedPagesRef.current.add(page.id);
|
||||
});
|
||||
}, [animatingPages, allPages, visiblePageCount]);
|
||||
// Display all pages - use edited or original document
|
||||
const displayedPages = displayDocument?.pages || [];
|
||||
|
||||
// Track color assignments by insertion order (files keep their color)
|
||||
const fileColorIndexMap = useFileColorMap(orderedFileIds);
|
||||
@@ -603,68 +462,32 @@ const PageEditor = ({
|
||||
gridItemRefsRef.current = refs;
|
||||
const fileColorIndex = page.originalFileId ? fileColorIndexMap.get(page.originalFileId) ?? 0 : 0;
|
||||
const isBoxSelected = boxSelectedIds.includes(page.id);
|
||||
const isFirstPage = index === 0;
|
||||
const isSecondaryPage = index > 0;
|
||||
const pageStyle: React.CSSProperties = {};
|
||||
|
||||
if (isFirstPage) {
|
||||
// Fade in first page after glide completes
|
||||
if (!firstPageVisible) {
|
||||
pageStyle.opacity = 0;
|
||||
} else {
|
||||
pageStyle.opacity = 1;
|
||||
pageStyle.transition = 'opacity 300ms ease-in-out';
|
||||
}
|
||||
// Keep first page on top during burst
|
||||
pageStyle.zIndex = 10;
|
||||
pageStyle.position = 'relative';
|
||||
} else if (isSecondaryPage) {
|
||||
// Burst from behind the real first page
|
||||
if (!animatingPages) {
|
||||
// Before animation: hide behind first page
|
||||
// Use data attributes to calculate in useLayoutEffect
|
||||
pageStyle.opacity = 0;
|
||||
pageStyle.transition = 'none';
|
||||
pageStyle.zIndex = -1;
|
||||
} else {
|
||||
// Burst animation is applied once in useLayoutEffect
|
||||
pageStyle.opacity = 1;
|
||||
pageStyle.zIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-page-id={page.id}
|
||||
data-page-number={index + 1}
|
||||
data-original-file-id={page.originalFileId}
|
||||
style={pageStyle}
|
||||
>
|
||||
<PageThumbnail
|
||||
key={page.id}
|
||||
page={page}
|
||||
index={index}
|
||||
totalPages={displayDocument.pages.length}
|
||||
originalFile={(page as any).originalFileId ? selectors.getFile((page as any).originalFileId) : undefined}
|
||||
fileColorIndex={fileColorIndex}
|
||||
selectedPageIds={selectedPageIds}
|
||||
selectionMode={selectionMode}
|
||||
movingPage={movingPage}
|
||||
isAnimating={isAnimating}
|
||||
isBoxSelected={isBoxSelected}
|
||||
clearBoxSelection={clearBoxSelection}
|
||||
activeDragIds={activeDragIds}
|
||||
justMoved={justMoved}
|
||||
pageRefs={refs}
|
||||
dragHandleProps={dragHandleProps}
|
||||
onReorderPages={handleReorderPages}
|
||||
onTogglePage={togglePage}
|
||||
onAnimateReorder={animateReorder}
|
||||
onExecuteCommand={executeCommand}
|
||||
onSetStatus={() => {}}
|
||||
onSetMovingPage={setMovingPage}
|
||||
onDeletePage={handleDeletePage}
|
||||
createRotateCommand={createRotateCommand}
|
||||
<PageThumbnail
|
||||
key={page.id}
|
||||
page={page}
|
||||
index={index}
|
||||
totalPages={displayDocument.pages.length}
|
||||
originalFile={(page as any).originalFileId ? selectors.getFile((page as any).originalFileId) : undefined}
|
||||
fileColorIndex={fileColorIndex}
|
||||
selectedPageIds={selectedPageIds}
|
||||
selectionMode={selectionMode}
|
||||
movingPage={movingPage}
|
||||
isAnimating={isAnimating}
|
||||
isBoxSelected={isBoxSelected}
|
||||
clearBoxSelection={clearBoxSelection}
|
||||
activeDragIds={activeDragIds}
|
||||
justMoved={justMoved}
|
||||
pageRefs={refs}
|
||||
dragHandleProps={dragHandleProps}
|
||||
onReorderPages={handleReorderPages}
|
||||
onTogglePage={togglePage}
|
||||
onAnimateReorder={animateReorder}
|
||||
onExecuteCommand={executeCommand}
|
||||
onSetStatus={() => {}}
|
||||
onSetMovingPage={setMovingPage}
|
||||
onDeletePage={handleDeletePage}
|
||||
createRotateCommand={createRotateCommand}
|
||||
createDeleteCommand={createDeleteCommand}
|
||||
createSplitCommand={createSplitCommand}
|
||||
pdfDocument={displayDocument}
|
||||
@@ -673,7 +496,6 @@ const PageEditor = ({
|
||||
onInsertFiles={handleInsertFiles}
|
||||
zoomLevel={zoomLevel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
/* PageEditorSpreadTransition.module.css - Spreading animation styles */
|
||||
|
||||
:root {
|
||||
--spread-duration: 600ms;
|
||||
--spread-easing: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
/* Background screenshot that fades out during animation */
|
||||
.backgroundScreenshot {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-size: 100% 100%;
|
||||
background-position: top left;
|
||||
background-repeat: no-repeat;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
transition: opacity 200ms ease-out;
|
||||
}
|
||||
|
||||
/* Individual page thumbnail that animates from file card to grid position */
|
||||
.spreadingPage {
|
||||
will-change: transform;
|
||||
transform-origin: top left;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
border-radius: 0;
|
||||
transition: transform var(--spread-duration) var(--spread-easing);
|
||||
}
|
||||
|
||||
/* Accessibility - respect reduced motion preferences */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--spread-duration: 0ms;
|
||||
}
|
||||
|
||||
.backgroundScreenshot {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.spreadingPage {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@@ -1,505 +0,0 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
import { PAGE_EDITOR_TRANSITION } from '@app/constants/animations';
|
||||
import { getContainedImageRect, getCenteredFallbackRect } from '@app/utils/dom';
|
||||
import styles from '@app/components/pageEditor/PageEditorSpreadTransition.module.css';
|
||||
|
||||
type AnimationPhase = 'idle' | 'ready' | 'gliding';
|
||||
|
||||
const nextPaint = () => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
|
||||
const waitForElement = (
|
||||
selector: string,
|
||||
timeoutMs: number,
|
||||
shouldAbort?: () => boolean
|
||||
): Promise<HTMLElement | null> => {
|
||||
const existing = document.querySelector(selector) as HTMLElement | null;
|
||||
if (existing) {
|
||||
return Promise.resolve(existing);
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let resolved = false;
|
||||
const finish = (value: HTMLElement | null) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
observer.disconnect();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const found = document.querySelector(selector) as HTMLElement | null;
|
||||
if (found) {
|
||||
finish(found);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
const start = performance.now();
|
||||
const tick = () => {
|
||||
if (resolved) return;
|
||||
if (shouldAbort?.()) {
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
if (performance.now() - start >= timeoutMs) {
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
};
|
||||
|
||||
const waitForStableRect = (
|
||||
element: Element,
|
||||
options: { stableDurationMs: number; timeoutMs: number },
|
||||
shouldAbort?: () => boolean
|
||||
): Promise<DOMRect> => {
|
||||
return new Promise(resolve => {
|
||||
let lastRect = element.getBoundingClientRect();
|
||||
let lastChange = performance.now();
|
||||
const start = lastChange;
|
||||
let rafId: number | null = null;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
scheduleCheck();
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
observer.disconnect();
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
rafId = null;
|
||||
};
|
||||
|
||||
const check = () => {
|
||||
rafId = null;
|
||||
|
||||
if (shouldAbort?.()) {
|
||||
cleanup();
|
||||
resolve(element.getBoundingClientRect());
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const sizeChanged = Math.abs(rect.width - lastRect.width) > 1 ||
|
||||
Math.abs(rect.height - lastRect.height) > 1;
|
||||
|
||||
if (sizeChanged) {
|
||||
lastRect = rect;
|
||||
lastChange = performance.now();
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
const stableEnough = now - lastChange >= options.stableDurationMs;
|
||||
const timedOut = now - start >= options.timeoutMs;
|
||||
|
||||
if (stableEnough || timedOut) {
|
||||
cleanup();
|
||||
resolve(rect);
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleCheck();
|
||||
};
|
||||
|
||||
const scheduleCheck = () => {
|
||||
if (rafId === null) {
|
||||
rafId = requestAnimationFrame(check);
|
||||
}
|
||||
};
|
||||
|
||||
observer.observe(element);
|
||||
scheduleCheck();
|
||||
});
|
||||
};
|
||||
|
||||
const waitForTransitionEnd = (
|
||||
element: HTMLElement,
|
||||
options: { propertyName?: string; timeoutMs: number },
|
||||
shouldAbort?: () => boolean
|
||||
): Promise<void> => {
|
||||
return new Promise(resolve => {
|
||||
let resolved = false;
|
||||
|
||||
const finish = () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
element.removeEventListener('transitionend', handleEnd);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const handleEnd = (event: TransitionEvent) => {
|
||||
if (event.target !== element) return;
|
||||
if (options.propertyName && event.propertyName !== options.propertyName) return;
|
||||
finish();
|
||||
};
|
||||
|
||||
element.addEventListener('transitionend', handleEnd);
|
||||
|
||||
const start = performance.now();
|
||||
const tick = () => {
|
||||
if (resolved) return;
|
||||
if (shouldAbort?.()) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
if (performance.now() - start >= options.timeoutMs) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
};
|
||||
|
||||
const waitForOpacity = (
|
||||
element: HTMLElement,
|
||||
options: { targetOpacity: number; timeoutMs: number },
|
||||
shouldAbort?: () => boolean
|
||||
): Promise<void> => {
|
||||
return new Promise(resolve => {
|
||||
const start = performance.now();
|
||||
const tick = () => {
|
||||
if (shouldAbort?.()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const opacity = Number.parseFloat(getComputedStyle(element).opacity || '1');
|
||||
if (opacity >= options.targetOpacity) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (performance.now() - start >= options.timeoutMs) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* PageEditorSpreadTransition - First page glide animation
|
||||
*
|
||||
* Animation sequence:
|
||||
* 1. First page thumbnail glides from file card to its position in page editor grid
|
||||
* 2. Smoothly transitions size and position like viewer zoom
|
||||
*/
|
||||
export const PageEditorSpreadTransition: React.FC = () => {
|
||||
const { pageEditorTransition } = useNavigationState();
|
||||
const { actions } = useNavigationActions();
|
||||
const { selectors } = useFileState();
|
||||
const [animationPhase, setAnimationPhase] = useState<AnimationPhase>('idle');
|
||||
const [firstPageTargetRect, setFirstPageTargetRect] = useState<DOMRect | null>(null);
|
||||
const transitionRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastScreenshotRef = useRef<string | null>(null);
|
||||
|
||||
const isActive = pageEditorTransition?.isAnimating ?? false;
|
||||
const direction = pageEditorTransition?.direction ?? 'enter';
|
||||
const fileCardRects = pageEditorTransition?.fileCardRects ?? new Map();
|
||||
const filePageCounts = pageEditorTransition?.filePageCounts ?? new Map();
|
||||
const pageThumbnails = pageEditorTransition?.pageThumbnails ?? new Map();
|
||||
const screenshotRect = pageEditorTransition?.editorScreenshotRect ?? null;
|
||||
const shouldMaskLoading = Boolean(pageEditorTransition?.editorScreenshotUrl);
|
||||
const spreadDuration = shouldMaskLoading
|
||||
? PAGE_EDITOR_TRANSITION.SPREAD_DURATION
|
||||
: PAGE_EDITOR_TRANSITION.SPREAD_DURATION_FAST;
|
||||
|
||||
useEffect(() => {
|
||||
const currentUrl = pageEditorTransition?.editorScreenshotUrl ?? null;
|
||||
const previousUrl = lastScreenshotRef.current;
|
||||
|
||||
if (currentUrl && currentUrl !== previousUrl) {
|
||||
if (previousUrl?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previousUrl);
|
||||
}
|
||||
lastScreenshotRef.current = currentUrl;
|
||||
}
|
||||
|
||||
if (!isActive && lastScreenshotRef.current) {
|
||||
const urlToRevoke = lastScreenshotRef.current;
|
||||
if (urlToRevoke.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(urlToRevoke);
|
||||
}
|
||||
lastScreenshotRef.current = null;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (lastScreenshotRef.current) {
|
||||
const urlToRevoke = lastScreenshotRef.current;
|
||||
if (urlToRevoke.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(urlToRevoke);
|
||||
}
|
||||
lastScreenshotRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [pageEditorTransition?.editorScreenshotUrl, isActive]);
|
||||
|
||||
// Build animation data for all pages
|
||||
const pageAnimations = useMemo(() => {
|
||||
if (!isActive) return [];
|
||||
// For burst pages, we need the discovered first page rect
|
||||
if (!firstPageTargetRect) return [];
|
||||
|
||||
const animations: Array<{
|
||||
pageIndex: number;
|
||||
fileId: string;
|
||||
sourceRect: DOMRect;
|
||||
targetRect: DOMRect | null; // Will be set after pages render
|
||||
staggerDelay: number;
|
||||
thumbnailUrl: string | null;
|
||||
isFirstPage: boolean;
|
||||
}> = [];
|
||||
|
||||
let cumulativePageIndex = 0;
|
||||
const activeFiles = selectors.getFiles();
|
||||
|
||||
activeFiles.forEach((file, fileIndex) => {
|
||||
const fileId = file.fileId;
|
||||
const cardRect = fileCardRects.get(fileId);
|
||||
const pageCount = filePageCounts.get(fileId) || 0;
|
||||
|
||||
if (pageCount === 0) return;
|
||||
|
||||
if (cardRect) {
|
||||
for (let i = 0; i < pageCount; i++) {
|
||||
const pageIndex = cumulativePageIndex + i;
|
||||
const isFirstPage = i === 0;
|
||||
|
||||
// Get actual page thumbnail from the captured map
|
||||
const thumbnailUrl = isFirstPage ? pageThumbnails.get(fileId) || null : null;
|
||||
|
||||
// Target rect will be queried from actual DOM elements
|
||||
const targetRect = null;
|
||||
|
||||
// For the first page, source is the file card
|
||||
// For other pages, source is the first page's target position (they burst from there)
|
||||
const sourceRect = isFirstPage ? cardRect : firstPageTargetRect;
|
||||
|
||||
// No stagger - all pages burst simultaneously
|
||||
const staggerDelay = 0;
|
||||
|
||||
animations.push({
|
||||
pageIndex,
|
||||
fileId,
|
||||
sourceRect,
|
||||
targetRect,
|
||||
staggerDelay,
|
||||
thumbnailUrl,
|
||||
isFirstPage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
cumulativePageIndex += pageCount;
|
||||
});
|
||||
|
||||
return animations.slice(0, PAGE_EDITOR_TRANSITION.MAX_ANIMATED_PAGES);
|
||||
}, [isActive, fileCardRects, filePageCounts, pageThumbnails, firstPageTargetRect, selectors]);
|
||||
|
||||
// Get first page animations
|
||||
const firstPages = useMemo(() => pageAnimations.filter(p => p.isFirstPage), [pageAnimations]);
|
||||
|
||||
// Animation orchestration
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const shouldAbort = () => !mounted;
|
||||
|
||||
if (!isActive) {
|
||||
setAnimationPhase('idle');
|
||||
setFirstPageTargetRect(null);
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}
|
||||
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (prefersReducedMotion) {
|
||||
actions.endPageEditorTransition();
|
||||
return () => { mounted = false; };
|
||||
}
|
||||
|
||||
const runAnimation = async () => {
|
||||
if (!mounted) return;
|
||||
|
||||
// Phase 0: Wait for PageEditor to render and find first page's actual position
|
||||
const firstPage = await waitForElement('[data-page-number="1"]', 1000, shouldAbort);
|
||||
if (!mounted) return;
|
||||
|
||||
let firstPageRect: DOMRect;
|
||||
if (firstPage) {
|
||||
await waitForStableRect(firstPage, { stableDurationMs: 250, timeoutMs: 500 }, shouldAbort);
|
||||
if (!mounted) return;
|
||||
const img = firstPage.querySelector('img') as HTMLImageElement | null;
|
||||
firstPageRect = img
|
||||
? getContainedImageRect(img)
|
||||
: firstPage.getBoundingClientRect();
|
||||
} else {
|
||||
firstPageRect = getCenteredFallbackRect();
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Store the discovered first page position
|
||||
setFirstPageTargetRect(firstPageRect);
|
||||
|
||||
// Wait for pageAnimations to recalculate with the new target rect
|
||||
await nextPaint();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Phase 1: Ready - render element with initial transform
|
||||
setAnimationPhase('ready');
|
||||
|
||||
// Wait for browser to paint initial state
|
||||
await nextPaint();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Phase 2: Gliding - apply target transform
|
||||
const transitionEl = transitionRef.current;
|
||||
setAnimationPhase('gliding');
|
||||
if (transitionEl) {
|
||||
await waitForTransitionEnd(
|
||||
transitionEl,
|
||||
{ propertyName: 'transform', timeoutMs: spreadDuration + 150 },
|
||||
shouldAbort
|
||||
);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
window.dispatchEvent(new Event(PAGE_EDITOR_TRANSITION.GLIDE_COMPLETE_EVENT));
|
||||
|
||||
// Phase 3: Wait for first page fade in
|
||||
const renderedFirstPage = document.querySelector('[data-page-number="1"]') as HTMLElement | null;
|
||||
if (renderedFirstPage) {
|
||||
await waitForOpacity(renderedFirstPage, { targetOpacity: 1, timeoutMs: 350 }, shouldAbort);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
actions.endPageEditorTransition();
|
||||
};
|
||||
|
||||
runAnimation();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [isActive, actions, spreadDuration]);
|
||||
|
||||
if (!isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isReady = animationPhase === 'ready';
|
||||
const isGliding = animationPhase === 'gliding';
|
||||
const shouldRenderGlide = (isReady || isGliding) && firstPageTargetRect && firstPages.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Optional screenshot background */}
|
||||
{pageEditorTransition?.editorScreenshotUrl && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${screenshotRect?.top ?? 0}px`,
|
||||
left: `${screenshotRect?.left ?? 0}px`,
|
||||
width: `${screenshotRect?.width ?? window.innerWidth}px`,
|
||||
height: `${screenshotRect?.height ?? window.innerHeight}px`,
|
||||
opacity: isGliding ? 0 : 1,
|
||||
transition: 'opacity 200ms ease-out',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10000,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={pageEditorTransition.editorScreenshotUrl}
|
||||
alt="Loading..."
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'fill',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* First page(s) gliding animation */}
|
||||
{shouldRenderGlide && firstPages.map((anim, idx) => {
|
||||
const { sourceRect, thumbnailUrl } = anim;
|
||||
const targetRect = firstPageTargetRect;
|
||||
|
||||
// Calculate target transform values
|
||||
const translateX = targetRect.left - sourceRect.left;
|
||||
const translateY = targetRect.top - sourceRect.top;
|
||||
const scaleX = targetRect.width / sourceRect.width;
|
||||
const scaleY = targetRect.height / sourceRect.height;
|
||||
|
||||
const targetTransform = `translate3d(${translateX}px, ${translateY}px, 0) scale(${scaleX}, ${scaleY})`;
|
||||
|
||||
// Initial style - always at source position with identity transform
|
||||
// First file gets highest z-index, subsequent files stack below
|
||||
const initialStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
top: sourceRect.top,
|
||||
left: sourceRect.left,
|
||||
width: sourceRect.width,
|
||||
height: sourceRect.height,
|
||||
transform: 'translate3d(0px, 0px, 0) scale(1)',
|
||||
transformOrigin: 'top left',
|
||||
opacity: 1,
|
||||
zIndex: PAGE_EDITOR_TRANSITION.OVERLAY_Z_INDEX + 100 - idx,
|
||||
};
|
||||
|
||||
// Target style - applied when gliding
|
||||
const glideStyle: React.CSSProperties = isGliding
|
||||
? { transform: targetTransform }
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`first-${anim.fileId}`}
|
||||
className={styles.spreadingPage}
|
||||
ref={idx === 0 ? transitionRef : undefined}
|
||||
style={{
|
||||
['--spread-duration' as any]: `${spreadDuration}ms`,
|
||||
...initialStyle,
|
||||
...glideStyle,
|
||||
}}
|
||||
>
|
||||
{thumbnailUrl && (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt=""
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
background: '#ffffff',
|
||||
border: '1px solid var(--border-default)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Stack, Text, Switch } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
|
||||
|
||||
interface ConvertFromSvgSettingsProps {
|
||||
parameters: ConvertParameters;
|
||||
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ConvertFromSvgSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}: ConvertFromSvgSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="sm" data-testid="svg-pdf-options-section">
|
||||
<Text size="sm" fw={500}>{t("convert.svgPdfOptions", "SVG to PDF Options")}:</Text>
|
||||
|
||||
<Switch
|
||||
data-testid="combine-svgs-switch"
|
||||
label={t("convert.combineSvgs", "Combine SVGs into single PDF")}
|
||||
description={t("convert.combineSvgsDescription", "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG")}
|
||||
checked={parameters.imageOptions.combineImages}
|
||||
onChange={(event) => onParameterChange('imageOptions', {
|
||||
...parameters.imageOptions,
|
||||
combineImages: event.currentTarget.checked
|
||||
})}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
{t("convert.svgVectorNote", "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size.")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConvertFromSvgSettings;
|
||||
@@ -20,7 +20,6 @@ import ConvertToPdfaSettings from "@app/components/tools/convert/ConvertToPdfaSe
|
||||
import ConvertFromCbrSettings from "@app/components/tools/convert/ConvertFromCbrSettings";
|
||||
import ConvertToCbrSettings from "@app/components/tools/convert/ConvertToCbrSettings";
|
||||
import ConvertFromEbookSettings from "@app/components/tools/convert/ConvertFromEbookSettings";
|
||||
import ConvertFromSvgSettings from "@app/components/tools/convert/ConvertFromSvgSettings";
|
||||
import ConvertToEpubSettings from "@app/components/tools/convert/ConvertToEpubSettings";
|
||||
import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
|
||||
import {
|
||||
@@ -332,18 +331,6 @@ const ConvertSettings = ({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* SVG to PDF options */}
|
||||
{parameters.fromExtension === 'svg' && parameters.toExtension === 'pdf' && (
|
||||
<>
|
||||
<Divider />
|
||||
<ConvertFromSvgSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Web to PDF options */}
|
||||
{((isWebFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') ||
|
||||
(parameters.isSmartDetection && parameters.smartDetectionType === 'web')) ? (
|
||||
|
||||
@@ -41,9 +41,6 @@ const EmbedPdfViewerContent = ({
|
||||
const pdfContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
|
||||
|
||||
// Get viewer transition state for fade-in animation
|
||||
const { viewerTransition } = useNavigationState();
|
||||
|
||||
const {
|
||||
isThumbnailSidebarVisible,
|
||||
toggleThumbnailSidebar,
|
||||
@@ -566,8 +563,7 @@ const EmbedPdfViewerContent = ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
contain: 'layout style paint',
|
||||
opacity: viewerTransition.isAnimating ? 0 : 1,
|
||||
contain: 'layout style paint'
|
||||
}}>
|
||||
{/* Close Button - Only show in preview mode */}
|
||||
{onClose && previewFile && (
|
||||
@@ -639,8 +635,6 @@ const EmbedPdfViewerContent = ({
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
background: "transparent",
|
||||
opacity: viewerTransition.isAnimating ? 0 : 1,
|
||||
transition: 'opacity 300ms ease-in',
|
||||
}}
|
||||
>
|
||||
<div style={{ pointerEvents: "auto" }}>
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
/* ViewerZoomTransition.module.css - Smooth zoom animation for viewer transitions */
|
||||
|
||||
/* Animation timing - synced with constants/animations.ts */
|
||||
:root {
|
||||
--viewer-zoom-duration: 400ms; /* VIEWER_TRANSITION.ZOOM_DURATION */
|
||||
--viewer-zoom-easing: cubic-bezier(0.25, 0.46, 0.45, 0.94); /* VIEWER_TRANSITION.EASING */
|
||||
}
|
||||
|
||||
/* Backdrop that fades in during transition */
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
transition: opacity var(--viewer-zoom-duration) var(--viewer-zoom-easing);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.backdropVisible {
|
||||
opacity: 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
/* Thumbnail that zooms from source position to PDF page */
|
||||
.thumbnail {
|
||||
position: fixed;
|
||||
transition:
|
||||
transform var(--viewer-zoom-duration) var(--viewer-zoom-easing),
|
||||
border-radius var(--viewer-zoom-duration) var(--viewer-zoom-easing),
|
||||
box-shadow var(--viewer-zoom-duration) var(--viewer-zoom-easing);
|
||||
will-change: transform;
|
||||
transform-origin: top left;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.thumbnailZoomed {
|
||||
/* Dimensions controlled by inline styles (calculated from PDF page element) */
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Respect reduced motion preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--viewer-zoom-duration: 0ms;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.backdropVisible {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.thumbnailZoomed {
|
||||
/* Instant transition for reduced motion - dimensions from inline styles */
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
import { VIEWER_TRANSITION } from '@app/constants/animations';
|
||||
import { getCenteredFallbackRect } from '@app/utils/dom';
|
||||
import styles from '@app/components/viewer/ViewerZoomTransition.module.css';
|
||||
|
||||
/**
|
||||
* ViewerZoomTransition - Animated overlay for smooth transitions to viewer mode
|
||||
*
|
||||
* Creates a "zoom in" effect from source element (file card or page thumbnail)
|
||||
* to the actual rendered PDF page by animating a thumbnail overlay.
|
||||
*
|
||||
* Coordinates with EmbedPDF initialization to ensure smooth handoff.
|
||||
*/
|
||||
export const ViewerZoomTransition: React.FC = () => {
|
||||
const { viewerTransition } = useNavigationState();
|
||||
const { actions } = useNavigationActions();
|
||||
const [animationPhase, setAnimationPhase] = useState<'idle' | 'searching' | 'zooming'>('idle');
|
||||
const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
|
||||
const isExitTransition = viewerTransition.transitionDirection === 'exit';
|
||||
const shouldMaskLoading = Boolean(viewerTransition.editorScreenshotUrl);
|
||||
const zoomDuration = shouldMaskLoading
|
||||
? VIEWER_TRANSITION.ZOOM_DURATION
|
||||
: VIEWER_TRANSITION.ZOOM_DURATION_FAST;
|
||||
const getFallbackRect = () => getCenteredFallbackRect();
|
||||
|
||||
useEffect(() => {
|
||||
let transitionTimer: number | null = null;
|
||||
let isMounted = true;
|
||||
const shouldWaitForPdf = shouldMaskLoading;
|
||||
const zoomDurationMs = zoomDuration;
|
||||
|
||||
if (!viewerTransition.isAnimating) {
|
||||
setAnimationPhase('idle');
|
||||
setTargetRect(null);
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (transitionTimer !== null) {
|
||||
clearTimeout(transitionTimer);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setAnimationPhase('searching');
|
||||
|
||||
if (isExitTransition) {
|
||||
// EXIT: start at PDF page rect (already captured), zoom to file card
|
||||
const waitForFileCard = async (): Promise<DOMRect> => {
|
||||
const maxAttempts = 20;
|
||||
const delayMs = 50;
|
||||
const fileId = viewerTransition.exitFileId;
|
||||
if (!fileId) return getFallbackRect();
|
||||
|
||||
let card: Element | null = null;
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
card = document.querySelector(`[data-file-id="${fileId}"]`);
|
||||
if (card) break;
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
|
||||
if (!card) {
|
||||
console.warn('ViewerZoomTransition: file card not found, using fallback rect');
|
||||
return getFallbackRect();
|
||||
}
|
||||
|
||||
const img = card.querySelector('img') as HTMLImageElement | null;
|
||||
return img ? img.getBoundingClientRect() : card.getBoundingClientRect();
|
||||
};
|
||||
|
||||
waitForFileCard().then(cardRect => {
|
||||
if (!isMounted) return;
|
||||
|
||||
actions.startZoom();
|
||||
setTargetRect(cardRect);
|
||||
setAnimationPhase('zooming');
|
||||
|
||||
transitionTimer = window.setTimeout(() => {
|
||||
if (!isMounted) return;
|
||||
actions.endViewerTransition();
|
||||
}, zoomDurationMs);
|
||||
});
|
||||
} else {
|
||||
const waitForPdfPage = async (): Promise<DOMRect> => {
|
||||
const maxAttempts = 20;
|
||||
const delayMs = 50;
|
||||
|
||||
// Step 1: Find the element
|
||||
let pageElement: Element | null = null;
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
pageElement = document.querySelector('[data-page-index="0"]');
|
||||
if (pageElement) break;
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
|
||||
if (!pageElement) {
|
||||
console.warn('ViewerZoomTransition: PDF page not found, using fallback rect');
|
||||
return viewerTransition.sourceRect || getFallbackRect();
|
||||
}
|
||||
|
||||
// Step 2: Wait for size to stabilize
|
||||
let stableRect: DOMRect | null = null;
|
||||
let previousSize = { width: 0, height: 0 };
|
||||
const stabilityChecks = 5; // Check 5 times to ensure stability
|
||||
let stableCount = 0;
|
||||
|
||||
for (let i = 0; i < 10; i++) { // Max 500ms to stabilize
|
||||
const currentRect = pageElement.getBoundingClientRect();
|
||||
const currentSize = {
|
||||
width: currentRect.width,
|
||||
height: currentRect.height
|
||||
};
|
||||
|
||||
// Check if size has changed from previous measurement
|
||||
const sizeChanged = Math.abs(currentSize.width - previousSize.width) > 1 ||
|
||||
Math.abs(currentSize.height - previousSize.height) > 1;
|
||||
|
||||
if (sizeChanged) {
|
||||
// Size changed, reset stability counter
|
||||
stableCount = 0;
|
||||
} else {
|
||||
// Size unchanged, increment stability counter
|
||||
stableCount++;
|
||||
if (stableCount >= stabilityChecks) {
|
||||
// Size has been stable for multiple checks - use this
|
||||
stableRect = currentRect;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
previousSize = currentSize;
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
// Use stable rect if found, otherwise use latest measurement
|
||||
return stableRect || pageElement.getBoundingClientRect();
|
||||
};
|
||||
|
||||
waitForPdfPage().then(pdfPageRect => {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Found PDF page - trigger screenshot fade and start animation
|
||||
actions.startZoom(); // Triggers 200ms fade of screenshot
|
||||
setTargetRect(pdfPageRect);
|
||||
setAnimationPhase('zooming');
|
||||
|
||||
// After zoom completes, wait for PDF to be fully rendered before removing thumbnail
|
||||
transitionTimer = window.setTimeout(async () => {
|
||||
if (!isMounted) return;
|
||||
|
||||
if (shouldWaitForPdf) {
|
||||
// Wait for PDF to be ready (check for rendered canvas or content)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const pageElement = document.querySelector('[data-page-index="0"]');
|
||||
if (pageElement) {
|
||||
// Check if page has actual content rendered (canvas or img)
|
||||
const hasContent = pageElement.querySelector('canvas, img');
|
||||
if (hasContent) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove thumbnail now that PDF is ready (or timeout reached/skipped)
|
||||
actions.endViewerTransition();
|
||||
}, zoomDurationMs);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (transitionTimer !== null) {
|
||||
clearTimeout(transitionTimer);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
viewerTransition.isAnimating,
|
||||
viewerTransition.exitFileId,
|
||||
viewerTransition.sourceRect,
|
||||
isExitTransition,
|
||||
actions,
|
||||
shouldMaskLoading,
|
||||
zoomDuration
|
||||
]);
|
||||
|
||||
// Don't render if not animating or missing thumbnail
|
||||
if (!viewerTransition.isAnimating || !viewerTransition.sourceThumbnailUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { sourceThumbnailUrl } = viewerTransition;
|
||||
const initialRect = isExitTransition
|
||||
? (viewerTransition.exitTargetRect || viewerTransition.sourceRect || getFallbackRect())
|
||||
: (viewerTransition.sourceRect || getFallbackRect());
|
||||
|
||||
const initialRadius = '0';
|
||||
const targetRadius = '0';
|
||||
const initialWidth = Math.max(initialRect.width, 1);
|
||||
const initialHeight = Math.max(initialRect.height, 1);
|
||||
|
||||
// Calculate initial styles based on source element position
|
||||
const initialStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
top: `${initialRect.top}px`,
|
||||
left: `${initialRect.left}px`,
|
||||
width: `${initialWidth}px`,
|
||||
height: `${initialHeight}px`,
|
||||
borderRadius: initialRadius,
|
||||
overflow: 'hidden',
|
||||
zIndex: VIEWER_TRANSITION.OVERLAY_Z_INDEX,
|
||||
transform: 'translate3d(0px, 0px, 0) scale(1)',
|
||||
transformOrigin: 'top left',
|
||||
['--viewer-zoom-duration' as any]: `${zoomDuration}ms`,
|
||||
};
|
||||
|
||||
const targetTransform = targetRect
|
||||
? `translate3d(${targetRect.left - initialRect.left}px, ${targetRect.top - initialRect.top}px, 0) scale(${targetRect.width / initialWidth}, ${targetRect.height / initialHeight})`
|
||||
: null;
|
||||
|
||||
// Determine if we should apply zoom animation
|
||||
const shouldZoom = animationPhase === 'zooming' && targetTransform !== null;
|
||||
const zoomStyle: React.CSSProperties = shouldZoom && targetTransform
|
||||
? { transform: targetTransform, borderRadius: targetRadius }
|
||||
: {};
|
||||
|
||||
// Build class names for thumbnail
|
||||
const thumbnailClasses = [
|
||||
styles.thumbnail,
|
||||
shouldZoom && styles.thumbnailZoomed,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Animated thumbnail - no backdrop needed */}
|
||||
<div
|
||||
className={thumbnailClasses}
|
||||
style={{
|
||||
...initialStyle,
|
||||
...zoomStyle,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
src={sourceThumbnailUrl}
|
||||
alt="Transitioning to viewer"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
background: '#ffffff',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Shared animation constants for viewer transitions
|
||||
* Single source of truth for timing and easing across CSS and JS
|
||||
*/
|
||||
|
||||
export const VIEWER_TRANSITION = {
|
||||
/** Duration of zoom animation in milliseconds */
|
||||
ZOOM_DURATION: 400,
|
||||
|
||||
/** Faster zoom when not masking loading */
|
||||
ZOOM_DURATION_FAST: 250,
|
||||
|
||||
/** Duration of screenshot fade in milliseconds */
|
||||
SCREENSHOT_FADE_DURATION: 200,
|
||||
|
||||
/** Cubic bezier easing function (easeOutQuad) */
|
||||
EASING: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
|
||||
/** Z-index for transition overlays */
|
||||
OVERLAY_Z_INDEX: 10000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Page editor spreading animation constants
|
||||
* Used for fileEditor ↔ pageEditor transitions
|
||||
*/
|
||||
export const PAGE_EDITOR_TRANSITION = {
|
||||
/** Duration of spreading animation in milliseconds */
|
||||
SPREAD_DURATION: 300,
|
||||
|
||||
/** Faster spread when not masking loading */
|
||||
SPREAD_DURATION_FAST: 200,
|
||||
|
||||
/** Stagger delay between individual page animations in milliseconds */
|
||||
PAGE_STAGGER_DELAY: 30,
|
||||
|
||||
/** Maximum total stagger delay in milliseconds */
|
||||
MAX_TOTAL_STAGGER: 300,
|
||||
|
||||
/** Cubic bezier easing function (matches viewer) */
|
||||
EASING: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
|
||||
/** Z-index for transition overlay (above viewer transition) */
|
||||
OVERLAY_Z_INDEX: 10001,
|
||||
|
||||
/** Maximum pages to animate individually (performance threshold) */
|
||||
MAX_ANIMATED_PAGES: 100,
|
||||
|
||||
/** Event name fired when glide animation completes */
|
||||
GLIDE_COMPLETE_EVENT: 'page-editor-glide-complete',
|
||||
} as const;
|
||||
@@ -21,7 +21,6 @@ export const CONVERSION_ENDPOINTS = {
|
||||
'office-pdf': '/api/v1/convert/file/pdf',
|
||||
'pdf-image': '/api/v1/convert/pdf/img',
|
||||
'image-pdf': '/api/v1/convert/img/pdf',
|
||||
'svg-pdf': '/api/v1/convert/svg/pdf',
|
||||
'cbz-pdf': '/api/v1/convert/cbz/pdf',
|
||||
'pdf-cbz': '/api/v1/convert/pdf/cbz',
|
||||
'pdf-office-word': '/api/v1/convert/pdf/word',
|
||||
@@ -47,7 +46,6 @@ export const ENDPOINT_NAMES = {
|
||||
'office-pdf': 'file-to-pdf',
|
||||
'pdf-image': 'pdf-to-img',
|
||||
'image-pdf': 'img-to-pdf',
|
||||
'svg-pdf': 'svg-to-pdf',
|
||||
'cbz-pdf': 'cbz-to-pdf',
|
||||
'pdf-cbz': 'pdf-to-cbz',
|
||||
'pdf-office-word': 'pdf-to-word',
|
||||
@@ -173,8 +171,7 @@ export const EXTENSION_TO_ENDPOINT: Record<string, Record<string, string>> = {
|
||||
'xlsx': { 'pdf': 'file-to-pdf' }, 'xls': { 'pdf': 'file-to-pdf' }, 'ods': { 'pdf': 'file-to-pdf' },
|
||||
'pptx': { 'pdf': 'file-to-pdf' }, 'ppt': { 'pdf': 'file-to-pdf' }, 'odp': { 'pdf': 'file-to-pdf' },
|
||||
'jpg': { 'pdf': 'img-to-pdf' }, 'jpeg': { 'pdf': 'img-to-pdf' }, 'png': { 'pdf': 'img-to-pdf' },
|
||||
'gif': { 'pdf': 'img-to-pdf' }, 'bmp': { 'pdf': 'img-to-pdf' }, 'tiff': { 'pdf': 'img-to-pdf' }, 'webp': { 'pdf': 'img-to-pdf' },
|
||||
'svg': { 'pdf': 'svg-to-pdf' },
|
||||
'gif': { 'pdf': 'img-to-pdf' }, 'bmp': { 'pdf': 'img-to-pdf' }, 'tiff': { 'pdf': 'img-to-pdf' }, 'webp': { 'pdf': 'img-to-pdf' }, 'svg': { 'pdf': 'img-to-pdf' },
|
||||
'html': { 'pdf': 'html-to-pdf' },
|
||||
'zip': { 'pdf': 'html-to-pdf' },
|
||||
'md': { 'pdf': 'markdown-to-pdf' },
|
||||
|
||||
@@ -11,32 +11,6 @@ import { useToolRegistry } from '@app/contexts/ToolRegistryContext';
|
||||
* maintain clear separation of concerns.
|
||||
*/
|
||||
|
||||
// Viewer transition animation state
|
||||
export interface ViewerTransitionState {
|
||||
isAnimating: boolean;
|
||||
sourceRect: DOMRect | null;
|
||||
sourceThumbnailUrl: string | null;
|
||||
transitionType: 'fileEditor' | 'pageEditor' | null;
|
||||
editorScreenshotUrl: string | null;
|
||||
editorScreenshotRect: DOMRect | null;
|
||||
isZooming: boolean;
|
||||
transitionDirection: 'enter' | 'exit' | null;
|
||||
exitTargetRect: DOMRect | null;
|
||||
exitFileId: string | null;
|
||||
}
|
||||
|
||||
// Page editor spreading animation state
|
||||
export interface PageEditorTransitionState {
|
||||
isAnimating: boolean;
|
||||
direction: 'enter' | 'exit';
|
||||
fileCardRects: Map<string, DOMRect>;
|
||||
filePageCounts: Map<string, number>;
|
||||
pageThumbnails: Map<string, string>; // Map file ID to first page thumbnail URL
|
||||
targetPageRects: Map<string, DOMRect> | null;
|
||||
editorScreenshotUrl: string | null;
|
||||
editorScreenshotRect: DOMRect | null;
|
||||
}
|
||||
|
||||
// Navigation state
|
||||
interface NavigationContextState {
|
||||
workbench: WorkbenchType;
|
||||
@@ -44,8 +18,6 @@ interface NavigationContextState {
|
||||
hasUnsavedChanges: boolean;
|
||||
pendingNavigation: (() => void) | null;
|
||||
showNavigationWarning: boolean;
|
||||
viewerTransition: ViewerTransitionState;
|
||||
pageEditorTransition: PageEditorTransitionState | null;
|
||||
}
|
||||
|
||||
// Navigation actions
|
||||
@@ -55,15 +27,7 @@ type NavigationAction =
|
||||
| { type: 'SET_TOOL_AND_WORKBENCH'; payload: { toolId: ToolId | null; workbench: WorkbenchType } }
|
||||
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
|
||||
| { type: 'SET_PENDING_NAVIGATION'; payload: { navigationFn: (() => void) | null } }
|
||||
| { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } }
|
||||
| { type: 'START_VIEWER_TRANSITION'; payload: { sourceRect: DOMRect; sourceThumbnailUrl: string; transitionType: 'fileEditor' | 'pageEditor'; editorScreenshotUrl?: string; editorScreenshotRect?: DOMRect } }
|
||||
| { type: 'END_VIEWER_TRANSITION' }
|
||||
| { type: 'START_ZOOM' }
|
||||
| { type: 'START_EXIT_TRANSITION'; payload: { exitTargetRect: DOMRect; sourceThumbnailUrl: string; exitFileId: string } }
|
||||
| { type: 'START_PAGE_EDITOR_ENTRY'; payload: PageEditorTransitionState }
|
||||
| { type: 'START_PAGE_EDITOR_EXIT'; payload: Partial<PageEditorTransitionState> }
|
||||
| { type: 'UPDATE_PAGE_EDITOR_TARGETS'; payload: { targetPageRects: Map<string, DOMRect> } }
|
||||
| { type: 'END_PAGE_EDITOR_TRANSITION' };
|
||||
| { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } };
|
||||
|
||||
// Navigation reducer
|
||||
const navigationReducer = (state: NavigationContextState, action: NavigationAction): NavigationContextState => {
|
||||
@@ -90,92 +54,6 @@ const navigationReducer = (state: NavigationContextState, action: NavigationActi
|
||||
case 'SHOW_NAVIGATION_WARNING':
|
||||
return { ...state, showNavigationWarning: action.payload.show };
|
||||
|
||||
case 'START_VIEWER_TRANSITION':
|
||||
return {
|
||||
...state,
|
||||
viewerTransition: {
|
||||
isAnimating: true,
|
||||
sourceRect: action.payload.sourceRect,
|
||||
sourceThumbnailUrl: action.payload.sourceThumbnailUrl,
|
||||
transitionType: action.payload.transitionType,
|
||||
editorScreenshotUrl: action.payload.editorScreenshotUrl || null,
|
||||
editorScreenshotRect: action.payload.editorScreenshotRect || null,
|
||||
isZooming: false,
|
||||
transitionDirection: 'enter',
|
||||
exitTargetRect: null,
|
||||
exitFileId: null
|
||||
}
|
||||
};
|
||||
|
||||
case 'START_EXIT_TRANSITION':
|
||||
return {
|
||||
...state,
|
||||
viewerTransition: {
|
||||
...state.viewerTransition,
|
||||
isAnimating: true,
|
||||
transitionDirection: 'exit',
|
||||
exitTargetRect: action.payload.exitTargetRect,
|
||||
sourceThumbnailUrl: action.payload.sourceThumbnailUrl,
|
||||
exitFileId: action.payload.exitFileId,
|
||||
sourceRect: null, // Will be calculated after fileEditor renders
|
||||
isZooming: false
|
||||
}
|
||||
};
|
||||
|
||||
case 'END_VIEWER_TRANSITION':
|
||||
return {
|
||||
...state,
|
||||
viewerTransition: {
|
||||
isAnimating: false,
|
||||
sourceRect: null,
|
||||
sourceThumbnailUrl: null,
|
||||
transitionType: null,
|
||||
editorScreenshotUrl: null,
|
||||
editorScreenshotRect: null,
|
||||
isZooming: false,
|
||||
transitionDirection: null,
|
||||
exitTargetRect: null,
|
||||
exitFileId: null
|
||||
}
|
||||
};
|
||||
|
||||
case 'START_ZOOM':
|
||||
return {
|
||||
...state,
|
||||
viewerTransition: {
|
||||
...state.viewerTransition,
|
||||
isZooming: true
|
||||
}
|
||||
};
|
||||
|
||||
case 'START_PAGE_EDITOR_ENTRY':
|
||||
return {
|
||||
...state,
|
||||
pageEditorTransition: action.payload
|
||||
};
|
||||
|
||||
case 'START_PAGE_EDITOR_EXIT':
|
||||
return {
|
||||
...state,
|
||||
pageEditorTransition: state.pageEditorTransition
|
||||
? { ...state.pageEditorTransition, ...action.payload }
|
||||
: null
|
||||
};
|
||||
|
||||
case 'UPDATE_PAGE_EDITOR_TARGETS':
|
||||
return {
|
||||
...state,
|
||||
pageEditorTransition: state.pageEditorTransition
|
||||
? { ...state.pageEditorTransition, targetPageRects: action.payload.targetPageRects }
|
||||
: null
|
||||
};
|
||||
|
||||
case 'END_PAGE_EDITOR_TRANSITION':
|
||||
return {
|
||||
...state,
|
||||
pageEditorTransition: null
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -187,20 +65,7 @@ const initialState: NavigationContextState = {
|
||||
selectedTool: null,
|
||||
hasUnsavedChanges: false,
|
||||
pendingNavigation: null,
|
||||
showNavigationWarning: false,
|
||||
viewerTransition: {
|
||||
isAnimating: false,
|
||||
sourceRect: null,
|
||||
sourceThumbnailUrl: null,
|
||||
transitionType: null,
|
||||
editorScreenshotUrl: null,
|
||||
editorScreenshotRect: null,
|
||||
isZooming: false,
|
||||
transitionDirection: null,
|
||||
exitTargetRect: null,
|
||||
exitFileId: null
|
||||
},
|
||||
pageEditorTransition: null
|
||||
showNavigationWarning: false
|
||||
};
|
||||
|
||||
// Navigation context actions interface
|
||||
@@ -217,20 +82,6 @@ export interface NavigationContextActions {
|
||||
cancelNavigation: () => void;
|
||||
clearToolSelection: () => void;
|
||||
handleToolSelect: (toolId: string) => void;
|
||||
startViewerTransition: (
|
||||
sourceRect: DOMRect,
|
||||
sourceThumbnailUrl: string,
|
||||
transitionType: 'fileEditor' | 'pageEditor',
|
||||
editorScreenshotUrl?: string,
|
||||
editorScreenshotRect?: DOMRect
|
||||
) => void;
|
||||
endViewerTransition: () => void;
|
||||
startZoom: () => void;
|
||||
startExitTransition: (exitTargetRect: DOMRect, sourceThumbnailUrl: string, exitFileId: string) => void;
|
||||
startPageEditorEntryTransition: (state: PageEditorTransitionState) => void;
|
||||
startPageEditorExitTransition: (state: Partial<PageEditorTransitionState>) => void;
|
||||
updatePageEditorTargets: (targetPageRects: Map<string, DOMRect>) => void;
|
||||
endPageEditorTransition: () => void;
|
||||
}
|
||||
|
||||
// Context state values
|
||||
@@ -240,8 +91,6 @@ export interface NavigationContextStateValue {
|
||||
hasUnsavedChanges: boolean;
|
||||
pendingNavigation: (() => void) | null;
|
||||
showNavigationWarning: boolean;
|
||||
viewerTransition: ViewerTransitionState;
|
||||
pageEditorTransition: PageEditorTransitionState | null;
|
||||
}
|
||||
|
||||
export interface NavigationContextActionsValue {
|
||||
@@ -403,7 +252,7 @@ export const NavigationProvider: React.FC<{
|
||||
|
||||
// Check for unsaved changes using registered checker or state
|
||||
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
|
||||
|
||||
|
||||
// If switching away from current tool and have unsaved changes, show warning
|
||||
if (hasUnsavedChanges && state.selectedTool && state.selectedTool !== toolId) {
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: performToolSelect } });
|
||||
@@ -413,47 +262,6 @@ export const NavigationProvider: React.FC<{
|
||||
}
|
||||
}, [toolRegistry, state.hasUnsavedChanges, state.selectedTool]);
|
||||
|
||||
const startViewerTransition = useCallback((
|
||||
sourceRect: DOMRect,
|
||||
sourceThumbnailUrl: string,
|
||||
transitionType: 'fileEditor' | 'pageEditor',
|
||||
editorScreenshotUrl?: string,
|
||||
editorScreenshotRect?: DOMRect
|
||||
) => {
|
||||
dispatch({
|
||||
type: 'START_VIEWER_TRANSITION',
|
||||
payload: { sourceRect, sourceThumbnailUrl, transitionType, editorScreenshotUrl, editorScreenshotRect }
|
||||
});
|
||||
}, []);
|
||||
|
||||
const endViewerTransition = useCallback(() => {
|
||||
dispatch({ type: 'END_VIEWER_TRANSITION' });
|
||||
}, []);
|
||||
|
||||
const startZoom = useCallback(() => {
|
||||
dispatch({ type: 'START_ZOOM' });
|
||||
}, []);
|
||||
|
||||
const startExitTransition = useCallback((exitTargetRect: DOMRect, sourceThumbnailUrl: string, exitFileId: string) => {
|
||||
dispatch({ type: 'START_EXIT_TRANSITION', payload: { exitTargetRect, sourceThumbnailUrl, exitFileId } });
|
||||
}, []);
|
||||
|
||||
const startPageEditorEntryTransition = useCallback((transitionState: PageEditorTransitionState) => {
|
||||
dispatch({ type: 'START_PAGE_EDITOR_ENTRY', payload: transitionState });
|
||||
}, []);
|
||||
|
||||
const startPageEditorExitTransition = useCallback((transitionState: Partial<PageEditorTransitionState>) => {
|
||||
dispatch({ type: 'START_PAGE_EDITOR_EXIT', payload: transitionState });
|
||||
}, []);
|
||||
|
||||
const updatePageEditorTargets = useCallback((targetPageRects: Map<string, DOMRect>) => {
|
||||
dispatch({ type: 'UPDATE_PAGE_EDITOR_TARGETS', payload: { targetPageRects } });
|
||||
}, []);
|
||||
|
||||
const endPageEditorTransition = useCallback(() => {
|
||||
dispatch({ type: 'END_PAGE_EDITOR_TRANSITION' });
|
||||
}, []);
|
||||
|
||||
// Memoize the actions object to prevent unnecessary context updates
|
||||
// This is critical to avoid infinite loops when effects depend on actions
|
||||
const actions: NavigationContextActions = useMemo(() => ({
|
||||
@@ -469,14 +277,6 @@ export const NavigationProvider: React.FC<{
|
||||
cancelNavigation,
|
||||
clearToolSelection,
|
||||
handleToolSelect,
|
||||
startViewerTransition,
|
||||
endViewerTransition,
|
||||
startZoom,
|
||||
startExitTransition,
|
||||
startPageEditorEntryTransition,
|
||||
startPageEditorExitTransition,
|
||||
updatePageEditorTargets,
|
||||
endPageEditorTransition,
|
||||
}), [
|
||||
setWorkbench,
|
||||
setSelectedTool,
|
||||
@@ -490,14 +290,6 @@ export const NavigationProvider: React.FC<{
|
||||
cancelNavigation,
|
||||
clearToolSelection,
|
||||
handleToolSelect,
|
||||
startViewerTransition,
|
||||
endViewerTransition,
|
||||
startZoom,
|
||||
startExitTransition,
|
||||
startPageEditorEntryTransition,
|
||||
startPageEditorExitTransition,
|
||||
updatePageEditorTargets,
|
||||
endPageEditorTransition,
|
||||
]);
|
||||
|
||||
const stateValue: NavigationContextStateValue = {
|
||||
@@ -505,9 +297,7 @@ export const NavigationProvider: React.FC<{
|
||||
selectedTool: state.selectedTool,
|
||||
hasUnsavedChanges: state.hasUnsavedChanges,
|
||||
pendingNavigation: state.pendingNavigation,
|
||||
showNavigationWarning: state.showNavigationWarning,
|
||||
viewerTransition: state.viewerTransition,
|
||||
pageEditorTransition: state.pageEditorTransition
|
||||
showNavigationWarning: state.showNavigationWarning
|
||||
};
|
||||
|
||||
// Also memoize the context value to prevent unnecessary re-renders
|
||||
|
||||
@@ -15,8 +15,6 @@ export const shouldProcessFilesSeparately = (
|
||||
// Image to PDF with combineImages = false
|
||||
((isImageFormat(parameters.fromExtension) || parameters.fromExtension === 'image') &&
|
||||
parameters.toExtension === 'pdf' && !parameters.imageOptions.combineImages) ||
|
||||
// SVG to PDF with combineIntoSinglePdf = false
|
||||
(parameters.fromExtension === 'svg' && parameters.toExtension === 'pdf' && !parameters.imageOptions.combineImages) ||
|
||||
// PDF to image conversions (each PDF should generate its own image file)
|
||||
(parameters.fromExtension === 'pdf' && isImageFormat(parameters.toExtension)) ||
|
||||
// PDF to PDF/A conversions (each PDF should be processed separately)
|
||||
@@ -68,8 +66,6 @@ export const buildConvertFormData = (parameters: ConvertParameters, selectedFile
|
||||
formData.append("fitOption", imageOptions.fitOption);
|
||||
formData.append("colorType", imageOptions.colorType);
|
||||
formData.append("autoRotate", imageOptions.autoRotate.toString());
|
||||
} else if (fromExtension === 'svg' && toExtension === 'pdf') {
|
||||
formData.append("combineIntoSinglePdf", imageOptions.combineImages.toString());
|
||||
} else if ((fromExtension === 'html' || fromExtension === 'zip') && toExtension === 'pdf') {
|
||||
formData.append("zoom", htmlOptions.zoomLevel.toString());
|
||||
} else if ((fromExtension === 'eml' || fromExtension === 'msg') && toExtension === 'pdf') {
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
import { getContainedImageRect, getCenteredFallbackRect } from '@app/utils/dom';
|
||||
import type { WorkbenchType } from '@app/types/workbench';
|
||||
import type { FileId } from '@app/types/fileContext';
|
||||
|
||||
interface UsePageEditorTransitionParams {
|
||||
currentView: WorkbenchType;
|
||||
captureScreenshot: () => Promise<string | null>;
|
||||
activeFileId?: FileId;
|
||||
getScreenshotRect?: () => DOMRect | null;
|
||||
}
|
||||
|
||||
interface PageEditorTransitionHandlers {
|
||||
handleEntryTransition: (options?: { sourceRect?: DOMRect; sourceFileId?: FileId }) => Promise<void>;
|
||||
handleExitTransition: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to handle page editor entry and exit spreading transitions
|
||||
* Captures file card positions and orchestrates the spreading animation
|
||||
*/
|
||||
export function usePageEditorTransition({
|
||||
currentView,
|
||||
captureScreenshot,
|
||||
activeFileId,
|
||||
getScreenshotRect,
|
||||
}: UsePageEditorTransitionParams): PageEditorTransitionHandlers {
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const { selectors } = useFileState();
|
||||
|
||||
const decodeImage = useCallback(async (src: string) => {
|
||||
if (!src) return;
|
||||
try {
|
||||
const img = new Image();
|
||||
img.src = src;
|
||||
if ('decode' in img) {
|
||||
await img.decode();
|
||||
} else {
|
||||
await new Promise<void>((resolve) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => resolve();
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore decode failures; fallback is rendering with normal load.
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle entry transition (fileEditor → pageEditor)
|
||||
* Captures first page position and metadata for glide-then-burst animation
|
||||
*/
|
||||
const handleEntryTransition = useCallback(
|
||||
async (options) => {
|
||||
if (currentView !== 'fileEditor' && currentView !== 'viewer') {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileCardRects = new Map<string, DOMRect>();
|
||||
const filePageCounts = new Map<string, number>();
|
||||
const pageThumbnails = new Map<string, string>(); // Map file ID to first page thumbnail URL
|
||||
|
||||
// Get active files to capture their first page positions
|
||||
const activeFiles = selectors.getFiles();
|
||||
let cumulativePageIndex = 0;
|
||||
|
||||
const sourceFileId = options?.sourceFileId ?? activeFileId ?? undefined;
|
||||
let sourceRect = options?.sourceRect ?? null;
|
||||
if (currentView === 'viewer' && sourceFileId && !sourceRect) {
|
||||
const pageElement = document.querySelector('[data-page-index="0"]') as HTMLElement | null;
|
||||
sourceRect = pageElement ? pageElement.getBoundingClientRect() : getCenteredFallbackRect();
|
||||
}
|
||||
|
||||
activeFiles.forEach((file) => {
|
||||
const fileId = file.fileId;
|
||||
|
||||
if (currentView === 'fileEditor') {
|
||||
const card = document.querySelector(`[data-file-id="${fileId}"]`) as HTMLElement | null;
|
||||
if (card) {
|
||||
const img = card.querySelector('img') as HTMLImageElement | null;
|
||||
const rect = img
|
||||
? getContainedImageRect(img)
|
||||
: card.getBoundingClientRect();
|
||||
fileCardRects.set(fileId, rect);
|
||||
}
|
||||
} else if (currentView === 'viewer' && sourceFileId && sourceRect && fileId === sourceFileId) {
|
||||
fileCardRects.set(fileId, sourceRect);
|
||||
}
|
||||
|
||||
// Get page data from file metadata
|
||||
const stub = selectors.getStirlingFileStub(fileId);
|
||||
const pageCount = stub?.processedFile?.totalPages || 1;
|
||||
const pages = stub?.processedFile?.pages || [];
|
||||
|
||||
filePageCounts.set(fileId, pageCount);
|
||||
|
||||
// Capture only the first page thumbnail to keep transition state light
|
||||
const firstThumbnail = pages[0]?.thumbnail;
|
||||
if (firstThumbnail) {
|
||||
pageThumbnails.set(fileId, firstThumbnail);
|
||||
}
|
||||
|
||||
cumulativePageIndex += pageCount;
|
||||
});
|
||||
|
||||
// Only proceed if we have at least one file card
|
||||
if (fileCardRects.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Optional: capture screenshot for static background
|
||||
let screenshot: string | null = null;
|
||||
const screenshotRect = getScreenshotRect ? getScreenshotRect() : null;
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (!prefersReducedMotion) {
|
||||
try {
|
||||
screenshot = await captureScreenshot();
|
||||
} catch {
|
||||
screenshot = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (screenshot) {
|
||||
await decodeImage(screenshot);
|
||||
}
|
||||
|
||||
if (sourceFileId) {
|
||||
const sourceStub = selectors.getStirlingFileStub(sourceFileId);
|
||||
const firstThumb = sourceStub?.processedFile?.pages?.[0]?.thumbnail;
|
||||
if (firstThumb) {
|
||||
await decodeImage(firstThumb);
|
||||
}
|
||||
}
|
||||
|
||||
// Start page editor entry transition with first page glide
|
||||
navActions.startPageEditorEntryTransition({
|
||||
isAnimating: true,
|
||||
direction: 'enter',
|
||||
fileCardRects,
|
||||
filePageCounts,
|
||||
pageThumbnails,
|
||||
targetPageRects: null, // Will be set after PageEditor renders
|
||||
editorScreenshotUrl: screenshot,
|
||||
editorScreenshotRect: screenshotRect,
|
||||
});
|
||||
},
|
||||
[currentView, selectors, captureScreenshot, navActions, activeFileId, decodeImage, getScreenshotRect]
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle exit transition (pageEditor → fileEditor)
|
||||
* Captures current page positions in grid for reverse animation
|
||||
*/
|
||||
const handleExitTransition = useCallback(() => {
|
||||
if (currentView !== 'pageEditor') {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPageRects = new Map<string, DOMRect>();
|
||||
const fileCardRects = new Map<string, DOMRect>();
|
||||
const filePageCounts = new Map<string, number>();
|
||||
|
||||
// Query all page thumbnails (by data-page-id attribute)
|
||||
const pages = document.querySelectorAll('[data-page-id]');
|
||||
|
||||
pages.forEach((page) => {
|
||||
const pageId = page.getAttribute('data-page-id');
|
||||
const originalFileId = page.getAttribute('data-original-file-id');
|
||||
if (!pageId) return;
|
||||
|
||||
const img = page.querySelector('img') as HTMLImageElement | null;
|
||||
const rect = img
|
||||
? getContainedImageRect(img)
|
||||
: page.getBoundingClientRect();
|
||||
|
||||
targetPageRects.set(pageId, rect);
|
||||
|
||||
// Track which files have pages (for grouping in reverse animation)
|
||||
if (originalFileId) {
|
||||
if (!filePageCounts.has(originalFileId)) {
|
||||
filePageCounts.set(originalFileId, 0);
|
||||
}
|
||||
filePageCounts.set(originalFileId, filePageCounts.get(originalFileId)! + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Only proceed if we have pages
|
||||
if (targetPageRects.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start page editor exit transition
|
||||
// File card positions will be calculated after FileEditor renders
|
||||
navActions.startPageEditorExitTransition({
|
||||
isAnimating: true,
|
||||
direction: 'exit',
|
||||
targetPageRects, // Current page positions
|
||||
fileCardRects, // Will be filled after FileEditor renders
|
||||
filePageCounts,
|
||||
editorScreenshotUrl: null,
|
||||
editorScreenshotRect: null,
|
||||
});
|
||||
}, [currentView, navActions]);
|
||||
|
||||
return {
|
||||
handleEntryTransition,
|
||||
handleExitTransition,
|
||||
};
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
import { getThumbnailRect, getCenteredFallbackRect, getContainedImageRect } from '@app/utils/dom';
|
||||
import type { WorkbenchType } from '@app/types/workbench';
|
||||
import type { FileId } from '@app/types/fileContext';
|
||||
|
||||
interface UseViewerTransitionParams {
|
||||
activeFileIndex: number;
|
||||
currentView: WorkbenchType;
|
||||
captureScreenshot: () => Promise<string | null>;
|
||||
getScreenshotRect?: () => DOMRect | null;
|
||||
}
|
||||
|
||||
interface ViewerTransitionHandlers {
|
||||
handleEntryTransition: (fileId?: FileId, sourceRect?: DOMRect) => Promise<void>;
|
||||
handleExitTransition: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to handle viewer entry and exit transitions
|
||||
* Extracts transition orchestration logic from Workbench component
|
||||
*/
|
||||
export function useViewerTransition({
|
||||
activeFileIndex,
|
||||
currentView,
|
||||
captureScreenshot,
|
||||
getScreenshotRect,
|
||||
}: UseViewerTransitionParams): ViewerTransitionHandlers {
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const { selectors } = useFileState();
|
||||
const getActiveFile = () => selectors.getFiles()[activeFileIndex];
|
||||
|
||||
/**
|
||||
* Handle entry transition (fileEditor/pageEditor → viewer)
|
||||
* Captures screenshot, finds source thumbnail, starts zoom animation
|
||||
*/
|
||||
const handleEntryTransition = useCallback(
|
||||
async (fileId?: FileId, sourceRect?: DOMRect) => {
|
||||
if (currentView !== 'fileEditor' && currentView !== 'pageEditor') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture screenshot for smooth fade during transition
|
||||
let screenshot: string | null = null;
|
||||
const screenshotRect = currentView === 'pageEditor' ? getScreenshotRect?.() ?? null : null;
|
||||
if (currentView === 'fileEditor' || currentView === 'pageEditor') {
|
||||
try {
|
||||
screenshot = await captureScreenshot();
|
||||
} catch {
|
||||
screenshot = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Use passed fileId to find the file directly
|
||||
let targetFileId = fileId;
|
||||
if (!targetFileId) {
|
||||
// Fallback to activeFile if no fileId passed
|
||||
const activeFile = getActiveFile();
|
||||
targetFileId = activeFile?.fileId;
|
||||
}
|
||||
|
||||
if (!targetFileId) {
|
||||
return; // Can't animate without knowing which file
|
||||
}
|
||||
|
||||
// Find file stub directly by ID (no state dependency)
|
||||
const activeStub = selectors.getStirlingFileStub(targetFileId);
|
||||
const thumbnailUrl = activeStub?.thumbnailUrl || '';
|
||||
|
||||
if (!thumbnailUrl) {
|
||||
return; // Can't animate without thumbnail
|
||||
}
|
||||
|
||||
// Use passed sourceRect if available, otherwise search DOM
|
||||
let rect = sourceRect;
|
||||
if (!rect) {
|
||||
if (currentView === 'pageEditor') {
|
||||
// In page editor, find the first page thumbnail image
|
||||
const firstPageThumbnail = document.querySelector('[data-page-number="1"]') as HTMLElement | null;
|
||||
if (firstPageThumbnail) {
|
||||
const img = firstPageThumbnail.querySelector('img') as HTMLImageElement | null;
|
||||
// Calculate the actual rendered image size (accounting for objectFit: contain)
|
||||
rect = img ? getContainedImageRect(img) : firstPageThumbnail.getBoundingClientRect();
|
||||
} else {
|
||||
rect = getCenteredFallbackRect();
|
||||
}
|
||||
} else {
|
||||
// In file editor, find the file card
|
||||
const fileCard = document.querySelector(`[data-file-id="${targetFileId}"]`) as HTMLElement | null;
|
||||
rect = fileCard ? getThumbnailRect(fileCard) : getCenteredFallbackRect();
|
||||
}
|
||||
}
|
||||
|
||||
navActions.startViewerTransition(
|
||||
rect,
|
||||
thumbnailUrl,
|
||||
currentView,
|
||||
screenshot || undefined,
|
||||
screenshotRect || undefined
|
||||
);
|
||||
},
|
||||
[currentView, activeFileIndex, selectors, captureScreenshot, navActions, getScreenshotRect]
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle exit transition (viewer → fileEditor/pageEditor)
|
||||
* Finds PDF page position, finds target thumbnail, starts reverse zoom animation
|
||||
*/
|
||||
const handleExitTransition = useCallback(
|
||||
(targetView: 'fileEditor' | 'pageEditor') => {
|
||||
if (currentView !== 'viewer') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't animate when returning to pageEditor
|
||||
if (targetView === 'pageEditor') {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeFile = getActiveFile();
|
||||
const activeStub = activeFile ? selectors.getStirlingFileStub(activeFile.fileId) : null;
|
||||
const thumbnailUrl = activeStub?.thumbnailUrl || '';
|
||||
|
||||
if (activeFile && thumbnailUrl) {
|
||||
// Find current PDF page position (still in DOM)
|
||||
const pdfPageElement = document.querySelector('[data-page-index="0"]');
|
||||
|
||||
const exitTargetRect = pdfPageElement
|
||||
? pdfPageElement.getBoundingClientRect()
|
||||
: getCenteredFallbackRect();
|
||||
|
||||
// Start exit transition - file card position will be found after fileEditor renders
|
||||
navActions.startExitTransition(exitTargetRect, thumbnailUrl, activeFile.fileId);
|
||||
}
|
||||
},
|
||||
[currentView, activeFileIndex, selectors, navActions]
|
||||
);
|
||||
|
||||
return {
|
||||
handleEntryTransition,
|
||||
handleExitTransition,
|
||||
};
|
||||
}
|
||||
@@ -36,7 +36,6 @@ export class ThumbnailGenerationService {
|
||||
// Session-based thumbnail cache
|
||||
private thumbnailCache = new Map<FileId | string /* FIX ME: Page ID */, CachedThumbnail>();
|
||||
private maxCacheSizeBytes = 1024 * 1024 * 1024; // 1GB cache limit
|
||||
private maxCacheEntries = 20; // Hard cap to avoid runaway memory
|
||||
private currentCacheSize = 0;
|
||||
|
||||
// PDF document cache to reuse PDF instances and avoid creating multiple workers
|
||||
@@ -229,16 +228,6 @@ export class ThumbnailGenerationService {
|
||||
}
|
||||
|
||||
addThumbnailToCache(pageId: string, thumbnail: string): void {
|
||||
const existing = this.thumbnailCache.get(pageId);
|
||||
if (existing) {
|
||||
existing.lastUsed = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
while (this.thumbnailCache.size >= this.maxCacheEntries) {
|
||||
this.evictLeastRecentlyUsed();
|
||||
}
|
||||
|
||||
const sizeBytes = thumbnail.length * 2; // Rough estimate for base64 string
|
||||
|
||||
// Enforce cache size limits
|
||||
@@ -276,8 +265,7 @@ export class ThumbnailGenerationService {
|
||||
return {
|
||||
size: this.thumbnailCache.size,
|
||||
sizeBytes: this.currentCacheSize,
|
||||
maxSizeBytes: this.maxCacheSizeBytes,
|
||||
maxEntries: this.maxCacheEntries
|
||||
maxSizeBytes: this.maxCacheSizeBytes
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
||||
|
||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||
enableAnalytics: true,
|
||||
appVersion: '2.3.0',
|
||||
appVersion: '2.2.1',
|
||||
serverCertificateEnabled: false,
|
||||
enableAlphaFunctionality: false,
|
||||
serverPort: 8080,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import {
|
||||
CONVERSION_ENDPOINTS,
|
||||
ENDPOINT_NAMES,
|
||||
EXTENSION_TO_ENDPOINT,
|
||||
@@ -11,15 +11,15 @@ import {
|
||||
*/
|
||||
export const getEndpointName = (fromExtension: string, toExtension: string): string => {
|
||||
if (!fromExtension || !toExtension) return '';
|
||||
|
||||
|
||||
let endpointKey = EXTENSION_TO_ENDPOINT[fromExtension]?.[toExtension];
|
||||
|
||||
// If no explicit mapping exists and we're converting to PDF,
|
||||
|
||||
// If no explicit mapping exists and we're converting to PDF,
|
||||
// fall back to 'any' which uses file-to-pdf endpoint
|
||||
if (!endpointKey && toExtension === 'pdf' && fromExtension !== 'any') {
|
||||
endpointKey = EXTENSION_TO_ENDPOINT['any']?.[toExtension];
|
||||
}
|
||||
|
||||
|
||||
return endpointKey || '';
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@ export const getEndpointName = (fromExtension: string, toExtension: string): str
|
||||
export const getEndpointUrl = (fromExtension: string, toExtension: string): string => {
|
||||
const endpointName = getEndpointName(fromExtension, toExtension);
|
||||
if (!endpointName) return '';
|
||||
|
||||
|
||||
// Find the endpoint URL from CONVERSION_ENDPOINTS using the endpoint name
|
||||
for (const [key, endpoint] of Object.entries(CONVERSION_ENDPOINTS)) {
|
||||
if (ENDPOINT_NAMES[key as keyof typeof ENDPOINT_NAMES] === endpointName) {
|
||||
@@ -50,11 +50,7 @@ export const isConversionSupported = (fromExtension: string, toExtension: string
|
||||
* Checks if the given extension is an image format
|
||||
*/
|
||||
export const isImageFormat = (extension: string): boolean => {
|
||||
return ['png', 'jpg', 'jpeg', 'gif', 'tiff', 'bmp', 'webp'].includes(extension.toLowerCase());
|
||||
};
|
||||
|
||||
export const isSvgFormat = (extension: string): boolean => {
|
||||
return extension.toLowerCase() === 'svg';
|
||||
return ['png', 'jpg', 'jpeg', 'gif', 'tiff', 'bmp', 'webp', 'svg'].includes(extension.toLowerCase());
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -103,4 +99,4 @@ export const getAvailableToExtensions = (fromExtension: string): Array<{value: s
|
||||
return TO_FORMAT_OPTIONS.filter(option =>
|
||||
supportedExtensions.includes(option.value)
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* DOM utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Find a thumbnail image inside a file card element and return its bounding rect
|
||||
* Falls back to the card's own rect if no image is found
|
||||
*
|
||||
* @param cardElement - The file card element to search within
|
||||
* @returns The bounding rect of the thumbnail image, or the card itself if no image found
|
||||
*/
|
||||
export function getThumbnailRect(cardElement: Element): DOMRect {
|
||||
const thumbnailImg = cardElement.querySelector('img') as HTMLImageElement;
|
||||
return thumbnailImg
|
||||
? thumbnailImg.getBoundingClientRect()
|
||||
: cardElement.getBoundingClientRect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a centered fallback rectangle for animations when a DOM element is missing.
|
||||
*/
|
||||
export function getCenteredFallbackRect(): DOMRect {
|
||||
const width = 200;
|
||||
const height = 260;
|
||||
const centerX = window.innerWidth / 2;
|
||||
const centerY = window.innerHeight / 2;
|
||||
return new DOMRect(centerX - width / 2, centerY - height / 2, width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the actual rendered image rect for an img element with objectFit: contain
|
||||
* Accounts for letterboxing/pillarboxing when aspect ratios don't match
|
||||
*
|
||||
* @param img - The image element with objectFit: contain
|
||||
* @returns The bounding rect of the actual rendered image (excluding empty space)
|
||||
*/
|
||||
export function getContainedImageRect(img: HTMLImageElement): DOMRect {
|
||||
const imgRect = img.getBoundingClientRect();
|
||||
|
||||
// If image hasn't loaded yet, return the full rect
|
||||
if (!img.naturalWidth || !img.naturalHeight) {
|
||||
return imgRect;
|
||||
}
|
||||
|
||||
const naturalRatio = img.naturalWidth / img.naturalHeight;
|
||||
const displayRatio = imgRect.width / imgRect.height;
|
||||
|
||||
let actualWidth, actualHeight, offsetX, offsetY;
|
||||
|
||||
if (naturalRatio > displayRatio) {
|
||||
// Image is wider - constrained by width (letterboxed top/bottom)
|
||||
actualWidth = imgRect.width;
|
||||
actualHeight = imgRect.width / naturalRatio;
|
||||
offsetX = 0;
|
||||
offsetY = (imgRect.height - actualHeight) / 2;
|
||||
} else {
|
||||
// Image is taller - constrained by height (pillarboxed left/right)
|
||||
actualHeight = imgRect.height;
|
||||
actualWidth = imgRect.height * naturalRatio;
|
||||
offsetX = (imgRect.width - actualWidth) / 2;
|
||||
offsetY = 0;
|
||||
}
|
||||
|
||||
return new DOMRect(
|
||||
imgRect.left + offsetX,
|
||||
imgRect.top + offsetY,
|
||||
actualWidth,
|
||||
actualHeight
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { domToBlob } from 'modern-screenshot';
|
||||
|
||||
interface ScreenshotOptions {
|
||||
filter?: (node: Node) => boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
restoreScrollPosition?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a screenshot of a DOM element as a data URL
|
||||
*
|
||||
* @param element - The DOM element to capture
|
||||
* @returns Promise resolving to an object URL of the screenshot, or null if capture fails
|
||||
*/
|
||||
export async function captureElementScreenshot(
|
||||
element: HTMLElement,
|
||||
options: ScreenshotOptions = {}
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const {
|
||||
filter,
|
||||
width = window.innerWidth,
|
||||
height = window.innerHeight,
|
||||
restoreScrollPosition = true,
|
||||
} = options;
|
||||
const blob = await domToBlob(element, {
|
||||
width,
|
||||
height,
|
||||
style: {
|
||||
transform: 'none',
|
||||
},
|
||||
filter,
|
||||
features: { restoreScrollPosition },
|
||||
});
|
||||
|
||||
return URL.createObjectURL(blob);
|
||||
} catch (error) {
|
||||
console.warn('Failed to capture screenshot:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
|
||||
|
||||
if (imageDataUrl && typeof imageDataUrl === 'string' && imageDataUrl.startsWith('data:image')) {
|
||||
try {
|
||||
|
||||
// Convert data URL to bytes
|
||||
const base64Data = imageDataUrl.split(',')[1];
|
||||
const imageBytes = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0));
|
||||
@@ -205,7 +206,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
|
||||
} else if (imageDataUrl.includes('data:image/png')) {
|
||||
image = await pdfDoc.embedPng(imageBytes);
|
||||
} else {
|
||||
// Default to PNG for other formats (including converted SVGs)
|
||||
image = await pdfDoc.embedPng(imageBytes);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
||||
|
||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||
enableAnalytics: true,
|
||||
appVersion: '2.3.0',
|
||||
appVersion: '2.2.1',
|
||||
serverCertificateEnabled: false,
|
||||
enableAlphaFunctionality: false,
|
||||
enableDesktopInstallSlide: true,
|
||||
|
||||
Reference in New Issue
Block a user