diff --git a/Readme.md b/Readme.md index 8c65141..a6e95a3 100644 --- a/Readme.md +++ b/Readme.md @@ -18,7 +18,7 @@ This project is a comprehensive full‑stack web app for doing simple, local fil - Reorder PDF pages (files over 50 pages are rejected to avoid truncated exports) - Convert PDF to DOCX - Convert DOCX to PDF -- Rotate or flip PDF pages while preserving all source pages +- Rotate or flip PDF pages (including mirrored export for horizontal/vertical flips) while preserving all source pages - Add watermarks to PDFs - Sign PDFs diff --git a/backend/blueprints/dpi_converter.py b/backend/blueprints/dpi_converter.py index a6df718..eab42ca 100644 --- a/backend/blueprints/dpi_converter.py +++ b/backend/blueprints/dpi_converter.py @@ -1,5 +1,5 @@ import os -from flask import Blueprint, request, jsonify +from flask import Blueprint, request from PIL import Image, ImageFile from werkzeug.utils import secure_filename from utils.helpers import error, send_file_and_cleanup, success diff --git a/backend/blueprints/image.py b/backend/blueprints/image.py index f40202e..6fc051c 100644 --- a/backend/blueprints/image.py +++ b/backend/blueprints/image.py @@ -250,6 +250,13 @@ def compress_image(): if upload_error: return upload_error + requested_format = request.form.get("format", "original").lower() + if requested_format != "original" and requested_format not in COMPRESSION_FORMATS: + return error( + "Invalid format. Please choose one of: original, jpeg, webp, png.", + 400, + ) + img, file_bytes, image_error = validate_image_file(file) if image_error: @@ -259,8 +266,6 @@ def compress_image(): quality = max(1, min(100, quality)) - requested_format = request.form.get("format", "original").lower() - if requested_format == "original": # Keep the uploaded format, falling back to JPEG for anything the # compressor cannot re-encode. @@ -269,13 +274,8 @@ def compress_image(): if img.format in COMPRESSION_EXTENSIONS else "JPEG" ) - elif requested_format in COMPRESSION_FORMATS: - img_format = COMPRESSION_FORMATS[requested_format] else: - return error( - "Invalid format. Please choose one of: original, jpeg, webp, png.", - 400, - ) + img_format = COMPRESSION_FORMATS[requested_format] # JPEG has no alpha channel, so transparency is flattened onto white # instead of turning black. WebP only accepts RGB/RGBA input. @@ -393,4 +393,4 @@ def resize_image(img, filename, file_bytes): try: resized_img.close() except Exception: - pass \ No newline at end of file + pass diff --git a/backend/blueprints/pdf.py b/backend/blueprints/pdf.py index e7d1a9f..836224b 100644 --- a/backend/blueprints/pdf.py +++ b/backend/blueprints/pdf.py @@ -36,8 +36,6 @@ def convert_pdf_to_png(): # Read PDF into memory and open from bytes pdf_bytes = pdf_file.read() - target_lang = request.form.get("language", "eng") - # Extract DPI/resolution settings. Default to 72 DPI (standard screen resolution). # DPI determines the scaling: zoom = requested_dpi / 72 # For 150 DPI: zoom = 150/72 = 2.08 diff --git a/backend/blueprints/pdf_to_docx.py b/backend/blueprints/pdf_to_docx.py index 2e59e55..14b7a97 100644 --- a/backend/blueprints/pdf_to_docx.py +++ b/backend/blueprints/pdf_to_docx.py @@ -6,6 +6,7 @@ from flask import Blueprint, request from utils.helpers import error, send_file_and_cleanup +from utils.validators import validate_pdf_file, validate_uploaded_file pdf_docx_bp = Blueprint("pdf_docx", __name__) @@ -14,14 +15,15 @@ def convert_pdf_to_docx(): doc = None try: - if "file" not in request.files: - return error("No file provided") + pdf_file, filename, upload_error = validate_uploaded_file(request, "file") + if upload_error: + return upload_error - pdf_file = request.files["file"] - - if pdf_file.filename == "": - return error("No file selected") + pdf_error = validate_pdf_file(pdf_file, filename) + if pdf_error: + return pdf_error + pdf_bytes = pdf_file.read() try: doc = fitz.open(stream=pdf_bytes, filetype="pdf") except Exception: @@ -64,4 +66,4 @@ def convert_pdf_to_docx(): "Failed to convert the PDF to DOCX. The file may be corrupted " "or unsupported.", 500, - ) \ No newline at end of file + ) diff --git a/backend/blueprints/removebg.py b/backend/blueprints/removebg.py index 841e0a8..ccea105 100644 --- a/backend/blueprints/removebg.py +++ b/backend/blueprints/removebg.py @@ -2,7 +2,7 @@ import threading import numpy as np -from flask import Blueprint, jsonify, request, Response +from flask import Blueprint, jsonify, Response from PIL import Image, ImageFilter from rembg import remove from skimage import morphology diff --git a/backend/blueprints/watermark.py b/backend/blueprints/watermark.py index 33b360f..9a2857d 100644 --- a/backend/blueprints/watermark.py +++ b/backend/blueprints/watermark.py @@ -2,7 +2,6 @@ from PIL import Image, ImageDraw, ImageFont from utils.helpers import error import io -import os watermark_bp = Blueprint('watermark', __name__) @@ -96,7 +95,7 @@ def create_text_watermark(text, font_size, color, opacity): try: color_rgb = tuple(int(color.lstrip('#')[i:i+2], 16) for i in (0, 2, 4)) - except: + except (TypeError, ValueError): color_rgb = (255, 255, 255) alpha = int(255 * opacity / 100) diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..efb6845 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,5 @@ +[tool.ruff.lint] +# Keep CI on Ruff's established correctness-focused default rules. Ruff is +# installed unpinned by the workflow, so pinning the selection prevents future +# releases from unexpectedly enabling repository-wide style rule families. +select = ["E4", "E7", "E9", "F"] diff --git a/backend/test_app.py b/backend/test_app.py index 3c85f89..7a53c1c 100644 --- a/backend/test_app.py +++ b/backend/test_app.py @@ -1,4 +1,3 @@ -import os import io import pytest from flask import Flask diff --git a/backend/tests/test_path_traversal.py b/backend/tests/test_path_traversal.py index 1071aaf..55e7a79 100644 --- a/backend/tests/test_path_traversal.py +++ b/backend/tests/test_path_traversal.py @@ -14,7 +14,6 @@ def test_path_traversal_filename(client): assert "../" not in header assert "..\\" not in header -import io def test_nested_path_traversal(client): response = client.post( @@ -28,7 +27,6 @@ def test_nested_path_traversal(client): header = response.headers.get("Content-Disposition", "") assert "../" not in header -import io def test_windows_path_traversal(client): response = client.post( diff --git a/backend/tests/test_send_file_helper.py b/backend/tests/test_send_file_helper.py index 563eb7b..2363182 100644 --- a/backend/tests/test_send_file_helper.py +++ b/backend/tests/test_send_file_helper.py @@ -10,7 +10,6 @@ def test_normal_filename(app): ) assert "hello.txt" in response.headers["Content-Disposition"] -import io def test_path_traversal_removed(app): with app.test_request_context(): @@ -23,7 +22,6 @@ def test_path_traversal_removed(app): header = response.headers["Content-Disposition"] assert "../" not in header -import io def test_windows_traversal_removed(app): with app.test_request_context(): @@ -37,7 +35,6 @@ def test_windows_traversal_removed(app): assert "..\\" not in header -import io def test_absolute_path_removed(app): with app.test_request_context(): diff --git a/backend/tests/test_upload_security_headers.py b/backend/tests/test_upload_security_headers.py index 6aa9bfa..c4973cd 100644 --- a/backend/tests/test_upload_security_headers.py +++ b/backend/tests/test_upload_security_headers.py @@ -14,7 +14,6 @@ def test_removebg_path_traversal_filename(client): assert "../" not in header assert "..\\" not in header -import io def test_removebg_nested_path(client): response = client.post( @@ -31,7 +30,6 @@ def test_removebg_nested_path(client): header = response.headers.get("Content-Disposition", "") assert "../" not in header -import io def test_removebg_windows_path(client): response = client.post( @@ -48,7 +46,6 @@ def test_removebg_windows_path(client): header = response.headers.get("Content-Disposition", "") assert "..\\" not in header -import io def test_removebg_absolute_path(client): response = client.post( diff --git a/frontend/src/pages/PdfRotateFlip.tsx b/frontend/src/pages/PdfRotateFlip.tsx index 82b93c2..beee315 100644 --- a/frontend/src/pages/PdfRotateFlip.tsx +++ b/frontend/src/pages/PdfRotateFlip.tsx @@ -1,9 +1,9 @@ import { useState, useRef, useEffect } from "react"; import * as pdfjsLib from "pdfjs-dist/legacy/build/pdf"; import pdfWorker from "pdfjs-dist/legacy/build/pdf.worker.min.mjs?url"; -import { PDFDocument, degrees } from "pdf-lib"; -import { buildExportPages } from "../utils/pdfPageSelection"; import { Toaster, toast } from "sonner"; +import { buildExportPages } from "../utils/pdfPageSelection"; +import { buildTransformedPdf } from "../utils/pdfPageTransforms"; import { motion } from "framer-motion"; import { RotateCcw, @@ -199,40 +199,19 @@ const transformAndDownload = async () => { try { const arrayBuffer = await file.arrayBuffer(); - const originalPdfDoc = await PDFDocument.load(arrayBuffer); - const newPdfDoc = await PDFDocument.create(); - const exportPages = buildExportPages( previews, - originalPdfDoc.getPageCount(), + totalPages ?? previews.length, removedPageNumbers, ); - const total = exportPages.length; - - for (let i = 0; i < exportPages.length; i++) { - const previewItem = exportPages[i]; - - const originalIndex = previewItem.originalPageNum - 1; - - const [copiedPage] = await newPdfDoc.copyPages( - originalPdfDoc, - [originalIndex] - ); - const shouldTransform = - scope === "all" || - selectedPreviewPages.includes(previewItem.originalPageNum); - - if (shouldTransform) { - copiedPage.setRotation(degrees(previewItem.currentRotation)); - } - - newPdfDoc.addPage(copiedPage); - - setProgress(Math.round(((i + 1) / total) * 100)); - } - - const pdfBytes = await newPdfDoc.save(); + const pdfBytes = await buildTransformedPdf( + arrayBuffer, + exportPages, + (originalPageNum) => + scope === "all" || selectedPreviewPages.includes(originalPageNum), + setProgress, + ); const blob = new Blob([new Uint8Array(pdfBytes)], { type: "application/pdf", diff --git a/frontend/src/utils/pdfPageTransforms.test.ts b/frontend/src/utils/pdfPageTransforms.test.ts new file mode 100644 index 0000000..d6d3faa --- /dev/null +++ b/frontend/src/utils/pdfPageTransforms.test.ts @@ -0,0 +1,154 @@ +import { PDFDocument, rgb } from "pdf-lib"; +import { describe, expect, it } from "vitest"; + +import { applyPageTransform, buildTransformedPdf } from "./pdfPageTransforms"; + +async function createAsymmetricPdf() { + const doc = await PDFDocument.create(); + const page = doc.addPage([200, 100]); + page.drawRectangle({ + x: 10, + y: 10, + width: 40, + height: 20, + color: rgb(0.9, 0.1, 0.1), + }); + return doc.save(); +} + +function asBytes(bytes: Uint8Array) { + return Array.from(bytes); +} + +describe("applyPageTransform", () => { + it("keeps rotation when no flips are requested", async () => { + const sourceBytes = await createAsymmetricPdf(); + const source = await PDFDocument.load(sourceBytes); + const out = await PDFDocument.create(); + const [page] = await out.copyPages(source, [0]); + + applyPageTransform(page, { + currentRotation: 90, + currentFlippedH: false, + currentFlippedV: false, + }); + + expect(page.getRotation().angle).toBe(90); + }); + + it("applies flip transforms without changing page size", async () => { + const sourceBytes = await createAsymmetricPdf(); + const source = await PDFDocument.load(sourceBytes); + const out = await PDFDocument.create(); + const [page] = await out.copyPages(source, [0]); + const before = page.getSize(); + + applyPageTransform(page, { + currentRotation: 0, + currentFlippedH: true, + currentFlippedV: true, + }); + + expect(page.getSize()).toEqual(before); + }); +}); + +describe("buildTransformedPdf", () => { + it("exports flipped pages with different bytes than rotation-only output", async () => { + const sourceBytes = await createAsymmetricPdf(); + + const rotatedOnly = await buildTransformedPdf( + sourceBytes, + [ + { + originalPageNum: 1, + currentRotation: 0, + currentFlippedH: false, + currentFlippedV: false, + }, + ], + () => true, + ); + + const flippedHorizontal = await buildTransformedPdf( + sourceBytes, + [ + { + originalPageNum: 1, + currentRotation: 0, + currentFlippedH: true, + currentFlippedV: false, + }, + ], + () => true, + ); + + const flippedVertical = await buildTransformedPdf( + sourceBytes, + [ + { + originalPageNum: 1, + currentRotation: 0, + currentFlippedH: false, + currentFlippedV: true, + }, + ], + () => true, + ); + + const flippedBoth = await buildTransformedPdf( + sourceBytes, + [ + { + originalPageNum: 1, + currentRotation: 180, + currentFlippedH: true, + currentFlippedV: true, + }, + ], + () => true, + ); + + expect(asBytes(flippedHorizontal)).not.toEqual(asBytes(rotatedOnly)); + expect(asBytes(flippedVertical)).not.toEqual(asBytes(rotatedOnly)); + expect(asBytes(flippedHorizontal)).not.toEqual(asBytes(flippedVertical)); + expect(asBytes(flippedBoth)).not.toEqual(asBytes(rotatedOnly)); + + const flippedDoc = await PDFDocument.load(flippedHorizontal); + expect(flippedDoc.getPageCount()).toBe(1); + }); + + it("skips transforms outside the selected page scope", async () => { + const sourceBytes = await createAsymmetricPdf(); + + const untouched = await buildTransformedPdf( + sourceBytes, + [ + { + originalPageNum: 1, + currentRotation: 90, + currentFlippedH: true, + currentFlippedV: true, + }, + ], + () => false, + ); + + const transformed = await buildTransformedPdf( + sourceBytes, + [ + { + originalPageNum: 1, + currentRotation: 90, + currentFlippedH: true, + currentFlippedV: true, + }, + ], + () => true, + ); + + expect(asBytes(untouched)).not.toEqual(asBytes(transformed)); + const untouchedDoc = await PDFDocument.load(untouched); + expect(untouchedDoc.getPage(0).getRotation().angle).toBe(0); + }); +}); diff --git a/frontend/src/utils/pdfPageTransforms.ts b/frontend/src/utils/pdfPageTransforms.ts new file mode 100644 index 0000000..3167f97 --- /dev/null +++ b/frontend/src/utils/pdfPageTransforms.ts @@ -0,0 +1,61 @@ +import { PDFDocument, PDFPage, degrees } from "pdf-lib"; + +export type PageTransform = { + currentRotation: number; + currentFlippedH: boolean; + currentFlippedV: boolean; +}; + +function normalizeRotation(rotation: number) { + return ((rotation % 360) + 360) % 360; +} + +/** + * Apply preview flips to a copied page by mirroring content in-place, + * then set the page rotation to match the thumbnail preview. + */ +export function applyPageTransform(page: PDFPage, transform: PageTransform) { + const rotation = normalizeRotation(transform.currentRotation); + const flipH = Boolean(transform.currentFlippedH); + const flipV = Boolean(transform.currentFlippedV); + + if (flipH || flipV) { + const { width, height } = page.getSize(); + const xScale = flipH ? -1 : 1; + const yScale = flipV ? -1 : 1; + + page.scaleContent(xScale, yScale); + page.translateContent(flipH ? width : 0, flipV ? height : 0); + page.scaleAnnotations(xScale, yScale); + } + + page.setRotation(degrees(rotation)); +} + +export async function buildTransformedPdf( + sourceBytes: ArrayBuffer | Uint8Array, + pages: Array, + shouldTransform: (originalPageNum: number) => boolean, + onProgress?: (progress: number) => void, +) { + const originalPdfDoc = await PDFDocument.load(sourceBytes); + const newPdfDoc = await PDFDocument.create(); + const total = pages.length; + + for (let i = 0; i < pages.length; i++) { + const pageItem = pages[i]; + const originalIndex = pageItem.originalPageNum - 1; + const [copiedPage] = await newPdfDoc.copyPages(originalPdfDoc, [ + originalIndex, + ]); + + if (shouldTransform(pageItem.originalPageNum)) { + applyPageTransform(copiedPage, pageItem); + } + + newPdfDoc.addPage(copiedPage); + onProgress?.(Math.round(((i + 1) / total) * 100)); + } + + return newPdfDoc.save(); +}