feat: Enhance PDF tools with new reorder and watermark removal functionalities
- Added tests for rotating PDFs, removing watermarks, and reordering pages in the backend. - Implemented frontend logic to read page counts from uploaded PDFs and validate page orders. - Updated internationalization files to include new strings for reorder and watermark removal features. - Improved user feedback during page count reading and validation in the Reorder PDF component. - Ensured that the reorder functionality requires a complete permutation of pages.
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
import os
|
import os
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
@@ -10,6 +12,42 @@ from PIL import Image
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_TRAILING_TEXT_WATERMARK_RE = re.compile(
|
||||||
|
rb"q\s*"
|
||||||
|
rb"0 0 [-+]?\d*\.?\d+ [-+]?\d*\.?\d+ re\s*"
|
||||||
|
rb"W\s*n\s*"
|
||||||
|
rb"1 0 0 1 0 0 cm\s*"
|
||||||
|
rb"BT\s*/[^\s]+\s+[-+]?\d*\.?\d+\s+Tf\s+[-+]?\d*\.?\d+\s+TL\s+ET\s*"
|
||||||
|
rb"BT\s*/[^\s]+\s+[-+]?\d*\.?\d+\s+Tf\s+[-+]?\d*\.?\d+\s+TL\s+ET\s*"
|
||||||
|
rb"[-+]?\d*\.?\d+\s+[-+]?\d*\.?\d+\s+[-+]?\d*\.?\d+\s+rg\s*"
|
||||||
|
rb"/[^\s]+\s+gs\s*"
|
||||||
|
rb"q\s*[-+]?\d*\.?\d+\s+[-+]?\d*\.?\d+\s+[-+]?\d*\.?\d+\s+[-+]?\d*\.?\d+\s+[-+]?\d*\.?\d+\s+[-+]?\d*\.?\d+\s+cm\s*"
|
||||||
|
rb"BT\s+1 0 0 1\s+[-+]?\d*\.?\d+\s+[-+]?\d*\.?\d+\s+Tm\s*"
|
||||||
|
rb".*?"
|
||||||
|
rb"ET\s*Q\s*Q\s*\Z",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
_TRAILING_IMAGE_WATERMARK_RE = re.compile(
|
||||||
|
rb"q\s*"
|
||||||
|
rb"(?:"
|
||||||
|
rb"0 0 [-+]?\d*\.?\d+ [-+]?\d*\.?\d+ re\s*"
|
||||||
|
rb"W\s*n\s*"
|
||||||
|
rb"1 0 0 1 0 0 cm\s*"
|
||||||
|
rb"(?:BT\s*/[^\s]+\s+[-+]?\d*\.?\d+\s+Tf\s+[-+]?\d*\.?\d+\s+TL\s+ET\s*)?"
|
||||||
|
rb")?"
|
||||||
|
rb"(?:q\s*)?"
|
||||||
|
rb"(?:/[^\s]+\s+gs\s*)?"
|
||||||
|
rb"(?P<a>[-+]?\d*\.?\d+)\s+"
|
||||||
|
rb"(?P<b>[-+]?\d*\.?\d+)\s+"
|
||||||
|
rb"(?P<c>[-+]?\d*\.?\d+)\s+"
|
||||||
|
rb"(?P<d>[-+]?\d*\.?\d+)\s+"
|
||||||
|
rb"(?P<e>[-+]?\d*\.?\d+)\s+"
|
||||||
|
rb"(?P<f>[-+]?\d*\.?\d+)\s+cm\s*"
|
||||||
|
rb"/(?P<name>[^\s]+)\s+Do\s*Q(?:\s*Q)?\s*\Z",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PDFToolsError(Exception):
|
class PDFToolsError(Exception):
|
||||||
"""Custom exception for PDF tools failures."""
|
"""Custom exception for PDF tools failures."""
|
||||||
@@ -250,12 +288,7 @@ def rotate_pdf(
|
|||||||
if pages == "all":
|
if pages == "all":
|
||||||
rotate_indices = set(range(total_pages))
|
rotate_indices = set(range(total_pages))
|
||||||
else:
|
else:
|
||||||
rotate_indices = set()
|
rotate_indices = set(_parse_page_range(pages, total_pages))
|
||||||
for part in pages.split(","):
|
|
||||||
part = part.strip()
|
|
||||||
page = int(part)
|
|
||||||
if 1 <= page <= total_pages:
|
|
||||||
rotate_indices.add(page - 1)
|
|
||||||
|
|
||||||
rotated_count = 0
|
rotated_count = 0
|
||||||
for i, page in enumerate(reader.pages):
|
for i, page in enumerate(reader.pages):
|
||||||
@@ -715,8 +748,7 @@ def remove_watermark(
|
|||||||
output_path: str,
|
output_path: str,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Attempt to remove text-based watermarks from a PDF by rebuilding pages
|
Attempt to remove supported trailing watermark overlays from a PDF.
|
||||||
without the largest semi-transparent text overlay.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_path: Path to the input PDF
|
input_path: Path to the input PDF
|
||||||
@@ -730,30 +762,51 @@ def remove_watermark(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from PyPDF2 import PdfReader, PdfWriter
|
from PyPDF2 import PdfReader, PdfWriter
|
||||||
import re
|
from PyPDF2.generic import DecodedStreamObject, NameObject
|
||||||
|
|
||||||
reader = PdfReader(input_path)
|
reader = PdfReader(input_path)
|
||||||
writer = PdfWriter()
|
writer = PdfWriter()
|
||||||
total_pages = len(reader.pages)
|
total_pages = len(reader.pages)
|
||||||
|
cleaned_pages = 0
|
||||||
|
removed_watermarks = 0
|
||||||
|
|
||||||
for page in reader.pages:
|
for page in reader.pages:
|
||||||
# Extract page content and attempt to remove watermark-like artifacts
|
contents = page.get_contents()
|
||||||
# by rebuilding without operations that set very low opacity text
|
|
||||||
contents = page.get("/Contents")
|
|
||||||
if contents is not None:
|
if contents is not None:
|
||||||
# Simple approach: copy page as-is (full removal requires
|
cleaned_stream, removed_count = _strip_known_watermarks(
|
||||||
# content-stream parsing which varies by generator).
|
contents.get_data(),
|
||||||
pass
|
float(page.mediabox.width),
|
||||||
|
float(page.mediabox.height),
|
||||||
|
)
|
||||||
|
if removed_count > 0:
|
||||||
|
replacement_stream = DecodedStreamObject()
|
||||||
|
replacement_stream.set_data(cleaned_stream)
|
||||||
|
page[NameObject("/Contents")] = replacement_stream
|
||||||
|
cleaned_pages += 1
|
||||||
|
removed_watermarks += removed_count
|
||||||
|
|
||||||
writer.add_page(page)
|
writer.add_page(page)
|
||||||
|
|
||||||
|
if removed_watermarks == 0:
|
||||||
|
raise PDFToolsError(
|
||||||
|
"No removable watermark overlay was detected. "
|
||||||
|
"Flattened or embedded page-content watermarks are not currently supported."
|
||||||
|
)
|
||||||
|
|
||||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||||
with open(output_path, "wb") as f:
|
with open(output_path, "wb") as f:
|
||||||
writer.write(f)
|
writer.write(f)
|
||||||
|
|
||||||
logger.info(f"Remove watermark processed {total_pages} pages")
|
logger.info(
|
||||||
|
"Remove watermark cleaned %s watermark block(s) across %s/%s page(s)",
|
||||||
|
removed_watermarks,
|
||||||
|
cleaned_pages,
|
||||||
|
total_pages,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_pages": total_pages,
|
"total_pages": total_pages,
|
||||||
|
"cleaned_pages": cleaned_pages,
|
||||||
"output_size": os.path.getsize(output_path),
|
"output_size": os.path.getsize(output_path),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,6 +816,109 @@ def remove_watermark(
|
|||||||
raise PDFToolsError(f"Failed to remove watermark: {str(e)}")
|
raise PDFToolsError(f"Failed to remove watermark: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_known_watermarks(
|
||||||
|
stream_data: bytes,
|
||||||
|
page_width: float,
|
||||||
|
page_height: float,
|
||||||
|
) -> tuple[bytes, int]:
|
||||||
|
"""Remove supported trailing text or image watermark overlays from a page stream."""
|
||||||
|
cleaned_stream = stream_data
|
||||||
|
removed_count = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
cleaned_stream, removed_text_count = _strip_known_text_watermarks(cleaned_stream)
|
||||||
|
cleaned_stream, removed_image_count = _strip_known_image_watermarks(
|
||||||
|
cleaned_stream,
|
||||||
|
page_width,
|
||||||
|
page_height,
|
||||||
|
)
|
||||||
|
|
||||||
|
if removed_text_count == 0 and removed_image_count == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
removed_count += removed_text_count + removed_image_count
|
||||||
|
|
||||||
|
return cleaned_stream, removed_count
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_known_text_watermarks(stream_data: bytes) -> tuple[bytes, int]:
|
||||||
|
"""Remove trailing text watermark overlays generated by common PDF watermark flows."""
|
||||||
|
cleaned_stream = stream_data
|
||||||
|
removed_count = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
updated_stream, replacements = _TRAILING_TEXT_WATERMARK_RE.subn(
|
||||||
|
b"", cleaned_stream, count=1
|
||||||
|
)
|
||||||
|
if replacements == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
cleaned_stream = updated_stream.rstrip(b"\r\n")
|
||||||
|
removed_count += replacements
|
||||||
|
|
||||||
|
return cleaned_stream, removed_count
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_known_image_watermarks(
|
||||||
|
stream_data: bytes,
|
||||||
|
page_width: float,
|
||||||
|
page_height: float,
|
||||||
|
) -> tuple[bytes, int]:
|
||||||
|
"""Remove trailing image XObject watermark overlays when they match the supported pattern."""
|
||||||
|
cleaned_stream = stream_data
|
||||||
|
removed_count = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
match = _TRAILING_IMAGE_WATERMARK_RE.search(cleaned_stream)
|
||||||
|
if match is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not _is_probable_image_watermark(match, page_width, page_height):
|
||||||
|
break
|
||||||
|
|
||||||
|
cleaned_stream = cleaned_stream[:match.start()].rstrip(b"\r\n")
|
||||||
|
removed_count += 1
|
||||||
|
|
||||||
|
return cleaned_stream, removed_count
|
||||||
|
|
||||||
|
|
||||||
|
def _is_probable_image_watermark(
|
||||||
|
match: re.Match[bytes],
|
||||||
|
page_width: float,
|
||||||
|
page_height: float,
|
||||||
|
) -> bool:
|
||||||
|
"""Heuristic guardrail so only overlay-style trailing image blocks are stripped."""
|
||||||
|
name = match.group("name").lower()
|
||||||
|
if not name.startswith((b"formxob", b"im", b"img", b"image")):
|
||||||
|
return False
|
||||||
|
|
||||||
|
a = float(match.group("a"))
|
||||||
|
b = float(match.group("b"))
|
||||||
|
c = float(match.group("c"))
|
||||||
|
d = float(match.group("d"))
|
||||||
|
e = float(match.group("e"))
|
||||||
|
f = float(match.group("f"))
|
||||||
|
|
||||||
|
width = math.hypot(a, b)
|
||||||
|
height = math.hypot(c, d)
|
||||||
|
if width < 24 or height < 24:
|
||||||
|
return False
|
||||||
|
|
||||||
|
page_area = page_width * page_height
|
||||||
|
overlay_area = width * height
|
||||||
|
if page_area <= 0 or overlay_area <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
coverage_ratio = overlay_area / page_area
|
||||||
|
if coverage_ratio > 0.95:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if e == 0 and f == 0 and coverage_ratio > 0.6:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 11. Reorder PDF Pages
|
# 11. Reorder PDF Pages
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -792,15 +948,7 @@ def reorder_pdf_pages(
|
|||||||
writer = PdfWriter()
|
writer = PdfWriter()
|
||||||
total_pages = len(reader.pages)
|
total_pages = len(reader.pages)
|
||||||
|
|
||||||
if not page_order:
|
_validate_full_page_permutation(page_order, total_pages)
|
||||||
raise PDFToolsError("No page order specified.")
|
|
||||||
|
|
||||||
# Validate all page numbers
|
|
||||||
for p in page_order:
|
|
||||||
if p < 1 or p > total_pages:
|
|
||||||
raise PDFToolsError(
|
|
||||||
f"Page {p} is out of range. PDF has {total_pages} pages."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build new PDF in the requested order
|
# Build new PDF in the requested order
|
||||||
for p in page_order:
|
for p in page_order:
|
||||||
@@ -824,6 +972,43 @@ def reorder_pdf_pages(
|
|||||||
raise PDFToolsError(f"Failed to reorder PDF pages: {str(e)}")
|
raise PDFToolsError(f"Failed to reorder PDF pages: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_full_page_permutation(page_order: list[int], total_pages: int) -> None:
|
||||||
|
"""Require reorder requests to provide every page exactly once."""
|
||||||
|
if not page_order:
|
||||||
|
raise PDFToolsError("No page order specified.")
|
||||||
|
|
||||||
|
out_of_range = sorted({page for page in page_order if page < 1 or page > total_pages})
|
||||||
|
if out_of_range:
|
||||||
|
pages = ", ".join(str(page) for page in out_of_range)
|
||||||
|
raise PDFToolsError(
|
||||||
|
f"Page order contains out-of-range pages: {pages}. This PDF has {total_pages} pages."
|
||||||
|
)
|
||||||
|
|
||||||
|
duplicates: list[int] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
for page in page_order:
|
||||||
|
if page in seen and page not in duplicates:
|
||||||
|
duplicates.append(page)
|
||||||
|
seen.add(page)
|
||||||
|
|
||||||
|
missing = [page for page in range(1, total_pages + 1) if page not in seen]
|
||||||
|
if duplicates or missing or len(page_order) != total_pages:
|
||||||
|
details = ["Provide every page exactly once in the new order."]
|
||||||
|
if duplicates:
|
||||||
|
details.append(
|
||||||
|
f"Duplicate pages: {', '.join(str(page) for page in duplicates)}."
|
||||||
|
)
|
||||||
|
if missing:
|
||||||
|
details.append(
|
||||||
|
f"Missing pages: {', '.join(str(page) for page in missing)}."
|
||||||
|
)
|
||||||
|
raise PDFToolsError(
|
||||||
|
"Invalid page order. "
|
||||||
|
+ " ".join(details)
|
||||||
|
+ f" This PDF has {total_pages} pages."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 12. Extract Pages (explicit extraction to new PDF)
|
# 12. Extract Pages (explicit extraction to new PDF)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
8
backend/test_output_watermark.txt
Normal file
8
backend/test_output_watermark.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
....................................................... [100%]
|
||||||
|
============================== warnings summary ===============================
|
||||||
|
backend/tests/test_pdf_tools_service.py::TestMergePdfsService::test_merge_file_not_found_raises
|
||||||
|
C:\Users\ahmed\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.13_qbz5n2kfra8p0\LocalCache\local-packages\Python313\site-packages\PyPDF2\__init__.py:21: DeprecationWarning: PyPDF2 is deprecated. Please move to the pypdf library instead.
|
||||||
|
warnings.warn(
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
55 passed, 1 warning in 31.81s
|
||||||
@@ -4,9 +4,13 @@ import pytest
|
|||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
from app.services.pdf_tools_service import (
|
from app.services.pdf_tools_service import (
|
||||||
|
add_watermark,
|
||||||
merge_pdfs,
|
merge_pdfs,
|
||||||
split_pdf,
|
|
||||||
PDFToolsError,
|
PDFToolsError,
|
||||||
|
remove_watermark,
|
||||||
|
reorder_pdf_pages,
|
||||||
|
rotate_pdf,
|
||||||
|
split_pdf,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -71,6 +75,122 @@ class TestSplitPdfService:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
pytest.skip("PyPDF2 not installed")
|
pytest.skip("PyPDF2 not installed")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRotatePdfService:
|
||||||
|
def test_rotate_range_invalid_format_returns_clear_message(self, app, tmp_path):
|
||||||
|
"""Should raise a clear error for malformed page specs instead of failing generically."""
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
from PyPDF2 import PdfWriter
|
||||||
|
|
||||||
|
input_path = str(tmp_path / 'rotate-source.pdf')
|
||||||
|
output_path = str(tmp_path / 'rotate-output.pdf')
|
||||||
|
|
||||||
|
writer = PdfWriter()
|
||||||
|
writer.add_blank_page(width=612, height=792)
|
||||||
|
writer.add_blank_page(width=612, height=792)
|
||||||
|
with open(input_path, 'wb') as f:
|
||||||
|
writer.write(f)
|
||||||
|
|
||||||
|
with pytest.raises(PDFToolsError, match='Invalid page format'):
|
||||||
|
rotate_pdf(input_path, output_path, rotation=90, pages='1-two')
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip("PyPDF2 not installed")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoveWatermarkService:
|
||||||
|
def test_remove_text_watermark_from_reportlab_overlay(self, app, tmp_path):
|
||||||
|
"""Should remove text watermarks generated by the platform watermark flow."""
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
from reportlab.pdfgen import canvas
|
||||||
|
from PyPDF2 import PdfReader
|
||||||
|
|
||||||
|
input_path = str(tmp_path / 'source.pdf')
|
||||||
|
watermarked_path = str(tmp_path / 'watermarked.pdf')
|
||||||
|
output_path = str(tmp_path / 'cleaned.pdf')
|
||||||
|
|
||||||
|
c = canvas.Canvas(input_path)
|
||||||
|
c.drawString(100, 700, 'Hello world')
|
||||||
|
c.save()
|
||||||
|
|
||||||
|
add_watermark(input_path, watermarked_path, 'CONFIDENTIAL')
|
||||||
|
result = remove_watermark(watermarked_path, output_path)
|
||||||
|
|
||||||
|
extracted_text = PdfReader(output_path).pages[0].extract_text() or ''
|
||||||
|
|
||||||
|
assert result['total_pages'] == 1
|
||||||
|
assert result['cleaned_pages'] == 1
|
||||||
|
assert result['output_size'] > 0
|
||||||
|
assert os.path.exists(output_path)
|
||||||
|
assert 'Hello world' in extracted_text
|
||||||
|
assert 'CONFIDENTIAL' not in extracted_text
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip("PyPDF2/reportlab not installed")
|
||||||
|
|
||||||
|
def test_remove_image_watermark_overlay_from_trailing_xobject(self, app, tmp_path):
|
||||||
|
"""Should remove supported trailing image watermark overlays while preserving page text."""
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
from reportlab.pdfgen import canvas
|
||||||
|
from PyPDF2 import PdfReader, PdfWriter
|
||||||
|
|
||||||
|
input_path = str(tmp_path / 'source.pdf')
|
||||||
|
overlay_path = str(tmp_path / 'overlay.pdf')
|
||||||
|
watermarked_path = str(tmp_path / 'image-watermarked.pdf')
|
||||||
|
output_path = str(tmp_path / 'image-cleaned.pdf')
|
||||||
|
watermark_image_path = str(tmp_path / 'watermark.png')
|
||||||
|
|
||||||
|
c = canvas.Canvas(input_path)
|
||||||
|
c.drawString(100, 700, 'Hello world')
|
||||||
|
c.save()
|
||||||
|
|
||||||
|
Image.new('RGBA', (200, 80), (220, 38, 38, 96)).save(watermark_image_path)
|
||||||
|
|
||||||
|
c = canvas.Canvas(overlay_path)
|
||||||
|
c.drawImage(watermark_image_path, 180, 360, width=240, height=96, mask='auto')
|
||||||
|
c.save()
|
||||||
|
|
||||||
|
base_page = PdfReader(input_path).pages[0]
|
||||||
|
overlay_page = PdfReader(overlay_path).pages[0]
|
||||||
|
base_page.merge_page(overlay_page)
|
||||||
|
|
||||||
|
writer = PdfWriter()
|
||||||
|
writer.add_page(base_page)
|
||||||
|
with open(watermarked_path, 'wb') as f:
|
||||||
|
writer.write(f)
|
||||||
|
|
||||||
|
result = remove_watermark(watermarked_path, output_path)
|
||||||
|
cleaned_page = PdfReader(output_path).pages[0]
|
||||||
|
extracted_text = cleaned_page.extract_text() or ''
|
||||||
|
cleaned_stream = cleaned_page.get_contents().get_data()
|
||||||
|
|
||||||
|
assert result['total_pages'] == 1
|
||||||
|
assert result['cleaned_pages'] == 1
|
||||||
|
assert 'Hello world' in extracted_text
|
||||||
|
assert b'/FormXob' not in cleaned_stream
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip('PyPDF2/reportlab/Pillow not installed')
|
||||||
|
|
||||||
|
def test_remove_watermark_raises_when_no_supported_pattern_found(self, app, tmp_path):
|
||||||
|
"""Should fail clearly instead of returning an unchanged PDF as success."""
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
from reportlab.pdfgen import canvas
|
||||||
|
|
||||||
|
input_path = str(tmp_path / 'plain.pdf')
|
||||||
|
output_path = str(tmp_path / 'plain_cleaned.pdf')
|
||||||
|
|
||||||
|
c = canvas.Canvas(input_path)
|
||||||
|
c.drawString(72, 720, 'Plain PDF without watermark')
|
||||||
|
c.save()
|
||||||
|
|
||||||
|
with pytest.raises(PDFToolsError, match='No removable watermark overlay'):
|
||||||
|
remove_watermark(input_path, output_path)
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip("reportlab not installed")
|
||||||
|
|
||||||
def test_split_range_out_of_bounds_includes_total_pages(self, app, tmp_path):
|
def test_split_range_out_of_bounds_includes_total_pages(self, app, tmp_path):
|
||||||
"""Should raise a clear error when requested pages exceed document page count."""
|
"""Should raise a clear error when requested pages exceed document page count."""
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
@@ -109,3 +229,51 @@ class TestSplitPdfService:
|
|||||||
split_pdf(input_path, output_dir, mode='range', pages='1-2-3')
|
split_pdf(input_path, output_dir, mode='range', pages='1-2-3')
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pytest.skip("PyPDF2 not installed")
|
pytest.skip("PyPDF2 not installed")
|
||||||
|
|
||||||
|
|
||||||
|
class TestReorderPdfService:
|
||||||
|
def test_reorder_requires_full_page_permutation(self, app, tmp_path):
|
||||||
|
"""Should reject duplicates or omissions instead of silently dropping pages."""
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
from PyPDF2 import PdfWriter
|
||||||
|
|
||||||
|
input_path = str(tmp_path / 'reorder-source.pdf')
|
||||||
|
output_path = str(tmp_path / 'reorder-output.pdf')
|
||||||
|
|
||||||
|
writer = PdfWriter()
|
||||||
|
for _ in range(3):
|
||||||
|
writer.add_blank_page(width=612, height=792)
|
||||||
|
with open(input_path, 'wb') as f:
|
||||||
|
writer.write(f)
|
||||||
|
|
||||||
|
with pytest.raises(PDFToolsError, match='Provide every page exactly once'):
|
||||||
|
reorder_pdf_pages(input_path, output_path, [3, 1, 1])
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip('PyPDF2 not installed')
|
||||||
|
|
||||||
|
def test_reorder_accepts_full_page_permutation(self, app, tmp_path):
|
||||||
|
"""Should reorder when every page is present exactly once."""
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
from reportlab.pdfgen import canvas
|
||||||
|
from PyPDF2 import PdfReader
|
||||||
|
|
||||||
|
input_path = str(tmp_path / 'reorder-valid-source.pdf')
|
||||||
|
output_path = str(tmp_path / 'reorder-valid-output.pdf')
|
||||||
|
|
||||||
|
c = canvas.Canvas(input_path)
|
||||||
|
for page_number in range(1, 4):
|
||||||
|
c.drawString(100, 700, f'Page {page_number}')
|
||||||
|
c.showPage()
|
||||||
|
c.save()
|
||||||
|
|
||||||
|
result = reorder_pdf_pages(input_path, output_path, [3, 1, 2])
|
||||||
|
reader = PdfReader(output_path)
|
||||||
|
|
||||||
|
assert result['reordered_pages'] == 3
|
||||||
|
assert 'Page 3' in (reader.pages[0].extract_text() or '')
|
||||||
|
assert 'Page 1' in (reader.pages[1].extract_text() or '')
|
||||||
|
assert 'Page 2' in (reader.pages[2].extract_text() or '')
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip('PyPDF2/reportlab not installed')
|
||||||
@@ -85,6 +85,54 @@ class TestPdfToolsTaskRoutes:
|
|||||||
assert args[3] == 'CONFIDENTIAL'
|
assert args[3] == 'CONFIDENTIAL'
|
||||||
assert args[4] == 0.3
|
assert args[4] == 0.3
|
||||||
|
|
||||||
|
def test_remove_watermark_dispatches_task(self, client, monkeypatch):
|
||||||
|
"""Remove watermark route should dispatch the correct Celery task."""
|
||||||
|
mock_task = MagicMock()
|
||||||
|
mock_task.id = 'remove-wm-id'
|
||||||
|
mock_delay = MagicMock(return_value=mock_task)
|
||||||
|
|
||||||
|
monkeypatch.setattr('app.routes.pdf_tools.validate_actor_file',
|
||||||
|
lambda f, allowed_types, actor: ('test.pdf', 'pdf'))
|
||||||
|
monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path',
|
||||||
|
lambda ext, folder_type: ('remove-wm-id', '/tmp/test.pdf'))
|
||||||
|
monkeypatch.setattr('app.routes.pdf_tools.remove_watermark_task.delay', mock_delay)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||||
|
}
|
||||||
|
response = client.post('/api/pdf-tools/remove-watermark', data=data,
|
||||||
|
content_type='multipart/form-data')
|
||||||
|
assert response.status_code == 202
|
||||||
|
args = mock_delay.call_args[0]
|
||||||
|
assert args[0] == '/tmp/test.pdf'
|
||||||
|
assert args[1] == 'remove-wm-id'
|
||||||
|
assert args[2] == 'test.pdf'
|
||||||
|
|
||||||
|
def test_reorder_dispatches_task(self, client, monkeypatch):
|
||||||
|
"""Reorder route should dispatch with the parsed page order list."""
|
||||||
|
mock_task = MagicMock()
|
||||||
|
mock_task.id = 'reorder-id'
|
||||||
|
mock_delay = MagicMock(return_value=mock_task)
|
||||||
|
|
||||||
|
monkeypatch.setattr('app.routes.pdf_tools.validate_actor_file',
|
||||||
|
lambda f, allowed_types, actor: ('test.pdf', 'pdf'))
|
||||||
|
monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path',
|
||||||
|
lambda ext, folder_type: ('reorder-id', '/tmp/test.pdf'))
|
||||||
|
monkeypatch.setattr('app.routes.pdf_tools.reorder_pdf_task.delay', mock_delay)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||||
|
'page_order': '3,1,2',
|
||||||
|
}
|
||||||
|
response = client.post('/api/pdf-tools/reorder', data=data,
|
||||||
|
content_type='multipart/form-data')
|
||||||
|
assert response.status_code == 202
|
||||||
|
args = mock_delay.call_args[0]
|
||||||
|
assert args[0] == '/tmp/test.pdf'
|
||||||
|
assert args[1] == 'reorder-id'
|
||||||
|
assert args[2] == 'test.pdf'
|
||||||
|
assert args[3] == [3, 1, 2]
|
||||||
|
|
||||||
def test_protect_dispatches_task(self, client, monkeypatch):
|
def test_protect_dispatches_task(self, client, monkeypatch):
|
||||||
"""Protect route should dispatch with password."""
|
"""Protect route should dispatch with password."""
|
||||||
mock_task = MagicMock()
|
mock_task = MagicMock()
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import { ArrowUpDown } from 'lucide-react';
|
import { ArrowUpDown } from 'lucide-react';
|
||||||
|
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist';
|
||||||
|
import pdfWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
|
||||||
import FileUploader from '@/components/shared/FileUploader';
|
import FileUploader from '@/components/shared/FileUploader';
|
||||||
import ProgressBar from '@/components/shared/ProgressBar';
|
import ProgressBar from '@/components/shared/ProgressBar';
|
||||||
import DownloadButton from '@/components/shared/DownloadButton';
|
import DownloadButton from '@/components/shared/DownloadButton';
|
||||||
@@ -11,10 +13,15 @@ import { useTaskPolling } from '@/hooks/useTaskPolling';
|
|||||||
import { generateToolSchema } from '@/utils/seo';
|
import { generateToolSchema } from '@/utils/seo';
|
||||||
import { useFileStore } from '@/stores/fileStore';
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
|
|
||||||
|
GlobalWorkerOptions.workerSrc = pdfWorker;
|
||||||
|
|
||||||
export default function ReorderPdf() {
|
export default function ReorderPdf() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
|
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
|
||||||
const [pageOrder, setPageOrder] = useState('');
|
const [pageOrder, setPageOrder] = useState('');
|
||||||
|
const [pageCount, setPageCount] = useState<number | null>(null);
|
||||||
|
const [pageCountError, setPageCountError] = useState<string | null>(null);
|
||||||
|
const [isReadingPageCount, setIsReadingPageCount] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
file, uploadProgress, isUploading, taskId,
|
file, uploadProgress, isUploading, taskId,
|
||||||
@@ -38,13 +45,130 @@ export default function ReorderPdf() {
|
|||||||
if (storeFile) { selectFile(storeFile); clearStoreFile(); }
|
if (storeFile) { selectFile(storeFile); clearStoreFile(); }
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let loadingTask: ReturnType<typeof getDocument> | null = null;
|
||||||
|
|
||||||
|
async function detectPageCount(selectedFile: File) {
|
||||||
|
setIsReadingPageCount(true);
|
||||||
|
setPageCount(null);
|
||||||
|
setPageCountError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = new Uint8Array(await selectedFile.arrayBuffer());
|
||||||
|
loadingTask = getDocument({ data });
|
||||||
|
const pdf = await loadingTask.promise;
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setPageCount(pdf.numPages);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPageCountError(t('tools.reorderPdf.pageCountFailed'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsReadingPageCount(false);
|
||||||
|
}
|
||||||
|
void loadingTask?.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
setPageCount(null);
|
||||||
|
setPageCountError(null);
|
||||||
|
setIsReadingPageCount(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
void detectPageCount(file);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
void loadingTask?.destroy();
|
||||||
|
};
|
||||||
|
}, [file, t]);
|
||||||
|
|
||||||
|
const getPageOrderError = (): string | null => {
|
||||||
|
if (!pageOrder.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReadingPageCount) {
|
||||||
|
return t('tools.reorderPdf.readingPageCount');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageCountError) {
|
||||||
|
return pageCountError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageCount === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = pageOrder
|
||||||
|
.split(',')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (parts.length === 0 || parts.some((part) => !/^\d+$/.test(part))) {
|
||||||
|
return t('tools.reorderPdf.orderInvalidFormat');
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = parts.map((part) => Number(part));
|
||||||
|
const outOfRange = [...new Set(values.filter((page) => page < 1 || page > pageCount))];
|
||||||
|
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const duplicates: number[] = [];
|
||||||
|
for (const page of values) {
|
||||||
|
if (seen.has(page) && !duplicates.includes(page)) {
|
||||||
|
duplicates.push(page);
|
||||||
|
}
|
||||||
|
seen.add(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing: number[] = [];
|
||||||
|
for (let page = 1; page <= pageCount; page += 1) {
|
||||||
|
if (!seen.has(page)) {
|
||||||
|
missing.push(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outOfRange.length === 0 && duplicates.length === 0 && missing.length === 0 && values.length === pageCount) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const details: string[] = [];
|
||||||
|
if (outOfRange.length > 0) {
|
||||||
|
details.push(t('tools.reorderPdf.orderOutOfRange', { pages: outOfRange.join(', ') }));
|
||||||
|
}
|
||||||
|
if (missing.length > 0) {
|
||||||
|
details.push(t('tools.reorderPdf.orderMissingPages', { pages: missing.join(', ') }));
|
||||||
|
}
|
||||||
|
if (duplicates.length > 0) {
|
||||||
|
details.push(t('tools.reorderPdf.orderDuplicatePages', { pages: duplicates.join(', ') }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${t('tools.reorderPdf.orderMustIncludeAllPages', { count: pageCount })} ${details.join(' ')}`.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageOrderError = getPageOrderError();
|
||||||
|
const canSubmit = Boolean(file && pageOrder.trim() && !pageOrderError && !isUploading);
|
||||||
|
|
||||||
const handleUpload = async () => {
|
const handleUpload = async () => {
|
||||||
if (!pageOrder.trim()) return;
|
if (!canSubmit) return;
|
||||||
const id = await startUpload();
|
const id = await startUpload();
|
||||||
if (id) setPhase('processing');
|
if (id) setPhase('processing');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => { reset(); setPhase('upload'); setPageOrder(''); };
|
const handleReset = () => {
|
||||||
|
reset();
|
||||||
|
setPhase('upload');
|
||||||
|
setPageOrder('');
|
||||||
|
setPageCount(null);
|
||||||
|
setPageCountError(null);
|
||||||
|
setIsReadingPageCount(false);
|
||||||
|
};
|
||||||
|
|
||||||
const schema = generateToolSchema({
|
const schema = generateToolSchema({
|
||||||
name: t('tools.reorderPdf.title'),
|
name: t('tools.reorderPdf.title'),
|
||||||
@@ -96,8 +220,23 @@ export default function ReorderPdf() {
|
|||||||
<p className="mt-2 text-xs text-slate-400 dark:text-slate-500">
|
<p className="mt-2 text-xs text-slate-400 dark:text-slate-500">
|
||||||
{t('tools.reorderPdf.orderHint')}
|
{t('tools.reorderPdf.orderHint')}
|
||||||
</p>
|
</p>
|
||||||
|
{isReadingPageCount && (
|
||||||
|
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{t('tools.reorderPdf.readingPageCount')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!isReadingPageCount && pageCount !== null && (
|
||||||
|
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{t('tools.reorderPdf.pageCount', { count: pageCount })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{pageOrderError && (
|
||||||
|
<div className="mt-3 rounded-xl bg-red-50 p-3 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800">
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-400">{pageOrderError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleUpload} disabled={!pageOrder.trim()}
|
<button onClick={handleUpload} disabled={!canSubmit}
|
||||||
className="btn-primary w-full disabled:opacity-50 disabled:cursor-not-allowed">
|
className="btn-primary w-full disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
{t('tools.reorderPdf.shortDesc')}
|
{t('tools.reorderPdf.shortDesc')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -636,16 +636,24 @@
|
|||||||
},
|
},
|
||||||
"removeWatermark": {
|
"removeWatermark": {
|
||||||
"title": "إزالة العلامة المائية",
|
"title": "إزالة العلامة المائية",
|
||||||
"description": "أزل العلامات المائية النصية من ملفات PDF تلقائياً.",
|
"description": "أزل العلامات المائية النصية وطبقات الصور المدعومة من ملفات PDF تلقائياً.",
|
||||||
"shortDesc": "إزالة العلامة المائية"
|
"shortDesc": "إزالة العلامة المائية"
|
||||||
},
|
},
|
||||||
"reorderPdf": {
|
"reorderPdf": {
|
||||||
"title": "إعادة ترتيب صفحات PDF",
|
"title": "إعادة ترتيب صفحات PDF",
|
||||||
"description": "أعد ترتيب صفحات PDF بأي ترتيب تريده.",
|
"description": "أعد ترتيب صفحات PDF بأي ترتيب تريده مع تضمين كل صفحة مرة واحدة بالضبط.",
|
||||||
"shortDesc": "إعادة الترتيب",
|
"shortDesc": "إعادة الترتيب",
|
||||||
"orderLabel": "ترتيب الصفحات الجديد",
|
"orderLabel": "ترتيب الصفحات الجديد",
|
||||||
"orderPlaceholder": "مثال: 3,1,2,5,4",
|
"orderPlaceholder": "مثال: 3,1,2,5,4",
|
||||||
"orderHint": "أدخل أرقام الصفحات مفصولة بفواصل بالترتيب المطلوب."
|
"orderHint": "أدخل كل رقم صفحة مرة واحدة بالضبط، مفصولاً بفواصل، بالترتيب المطلوب.",
|
||||||
|
"readingPageCount": "جارٍ قراءة عدد الصفحات الكلي...",
|
||||||
|
"pageCount": "عدد الصفحات المكتشف: {{count}}",
|
||||||
|
"pageCountFailed": "تعذر قراءة عدد الصفحات من هذا الملف. يرجى اختيار ملف آخر.",
|
||||||
|
"orderInvalidFormat": "استخدم أرقام صفحات مفصولة بفواصل فقط، مثل 3,1,2.",
|
||||||
|
"orderMustIncludeAllPages": "يجب تضمين كل الصفحات مرة واحدة بالضبط من 1 إلى {{count}}.",
|
||||||
|
"orderOutOfRange": "صفحات خارج النطاق: {{pages}}.",
|
||||||
|
"orderMissingPages": "الصفحات المفقودة: {{pages}}.",
|
||||||
|
"orderDuplicatePages": "الصفحات المكررة: {{pages}}."
|
||||||
},
|
},
|
||||||
"extractPages": {
|
"extractPages": {
|
||||||
"title": "استخراج صفحات PDF",
|
"title": "استخراج صفحات PDF",
|
||||||
@@ -883,12 +891,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"removeWatermark": {
|
"removeWatermark": {
|
||||||
"whatItDoes": "أزل العلامات المائية النصية من ملفات PDF تلقائياً. تكتشف الأداة وتزيل طبقات النص المائية مع الحفاظ على باقي محتوى المستند وتخطيطه.",
|
"whatItDoes": "أزل العلامات المائية النصية وطبقات الصور المدعومة من ملفات PDF تلقائياً. تكتشف الأداة طبقات العلامات القابلة للإزالة مع الحفاظ على باقي محتوى المستند وتخطيطه.",
|
||||||
"howToUse": ["ارفع ملف PDF بالعلامة المائية.", "تكتشف الأداة تلقائياً العلامات المائية النصية.", "انقر إزالة لمعالجة المستند.", "حمّل PDF النظيف بدون علامات مائية."],
|
"howToUse": ["ارفع ملف PDF الذي يحتوي على علامة مائية.", "تكتشف الأداة تلقائياً العلامات المائية النصية أو طبقات الصور المدعومة.", "انقر إزالة لمعالجة المستند.", "حمّل PDF النظيف بدون علامات مائية."],
|
||||||
"benefits": ["كشف تلقائي للعلامات المائية", "يحافظ على محتوى وتخطيط المستند", "يعمل مع العلامات المائية النصية", "مجاني وآمن", "لا حاجة لتثبيت برامج"],
|
"benefits": ["كشف تلقائي للعلامات المائية", "يحافظ على محتوى وتخطيط المستند", "يعمل مع العلامات النصية وطبقات الصور المدعومة", "مجاني وآمن", "لا حاجة لتثبيت برامج"],
|
||||||
"useCases": ["تنظيف إصدارات المسودة للتوزيع النهائي", "إزالة العلامات التجارية القديمة من المستندات", "تحضير نسخ نظيفة للأرشيف", "إزالة علامات العينة/التجربة من ملفات PDF المشتراة", "تنظيف المستندات الممسوحة ضوئياً من طبقات غير مرغوبة"],
|
"useCases": ["تنظيف إصدارات المسودة للتوزيع النهائي", "إزالة العلامات التجارية القديمة من المستندات", "تحضير نسخ نظيفة للأرشيف", "إزالة علامات العينة/التجربة من ملفات PDF المشتراة", "تنظيف المستندات الممسوحة ضوئياً من طبقات غير مرغوبة"],
|
||||||
"faq": [
|
"faq": [
|
||||||
{"q": "هل يمكن لهذه الأداة إزالة أي علامة مائية؟", "a": "تعمل الأداة بشكل أفضل مع العلامات المائية النصية. العلامات المائية المبنية على الصور قد تحتاج معالجة إضافية."},
|
{"q": "هل يمكن لهذه الأداة إزالة أي علامة مائية؟", "a": "تعمل الأداة بشكل أفضل مع العلامات المائية النصية وطبقات الصور المدعومة. العلامات المدمجة بعمق أو المسطحة قد تحتاج معالجة إضافية."},
|
||||||
{"q": "هل ستؤثر إزالة العلامة المائية على جودة المستند؟", "a": "لا، يتم إزالة نص العلامة المائية فقط. كل المحتوى الآخر يبقى سليماً."},
|
{"q": "هل ستؤثر إزالة العلامة المائية على جودة المستند؟", "a": "لا، يتم إزالة نص العلامة المائية فقط. كل المحتوى الآخر يبقى سليماً."},
|
||||||
{"q": "هل من القانوني إزالة العلامات المائية؟", "a": "أزل العلامات المائية فقط من المستندات التي تملكها أو لديك إذن بتعديلها. احترم حقوق النشر والملكية الفكرية."}
|
{"q": "هل من القانوني إزالة العلامات المائية؟", "a": "أزل العلامات المائية فقط من المستندات التي تملكها أو لديك إذن بتعديلها. احترم حقوق النشر والملكية الفكرية."}
|
||||||
]
|
]
|
||||||
@@ -927,14 +935,14 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"reorderPdf": {
|
"reorderPdf": {
|
||||||
"whatItDoes": "أعد ترتيب صفحات مستند PDF بأي ترتيب. حدد ترتيب صفحات مخصص لإعادة تنظيم مستندك بدون تقسيم وإعادة دمج.",
|
"whatItDoes": "أعد ترتيب صفحات مستند PDF بأي ترتيب. حدد permutation كاملة للصفحات لإعادة تنظيم مستندك بدون تقسيم وإعادة دمج.",
|
||||||
"howToUse": ["ارفع مستند PDF.", "أدخل ترتيب الصفحات الجديد (مثل 3,1,2,5,4).", "انقر إعادة ترتيب لتنظيم الصفحات.", "حمّل PDF المعاد ترتيبه."],
|
"howToUse": ["ارفع مستند PDF.", "أدخل ترتيب الصفحات الجديد مع تضمين كل صفحة مرة واحدة بالضبط (مثل 3,1,2,5,4).", "انقر إعادة ترتيب لتنظيم الصفحات.", "حمّل PDF المعاد ترتيبه."],
|
||||||
"benefits": ["ترتيب صفحات مخصص بصيغة بسيطة", "إعادة تنظيم بدون تقسيم ودمج", "يعمل على أي مستند PDF", "مجاني ومعالجة سريعة", "بدون فقدان الجودة"],
|
"benefits": ["ترتيب صفحات مخصص بصيغة بسيطة", "يمنع فقدان الصفحات أو تكرارها بالخطأ", "يعمل على أي مستند PDF", "مجاني ومعالجة سريعة", "بدون فقدان الجودة"],
|
||||||
"useCases": ["نقل ملحق إلى مقدمة المستند", "إعادة تنظيم عرض تقديمي لجمهور مختلف", "إصلاح ترتيب الصفحات في المستندات الممسوحة ضوئياً", "إنشاء ترتيبات مستندات مخصصة من ملفات PDF موجودة", "إعادة ترتيب الأقسام لتدفق مستند جديد"],
|
"useCases": ["نقل ملحق إلى مقدمة المستند", "إعادة تنظيم عرض تقديمي لجمهور مختلف", "إصلاح ترتيب الصفحات في المستندات الممسوحة ضوئياً", "إنشاء ترتيبات مستندات مخصصة من ملفات PDF موجودة", "إعادة ترتيب الأقسام لتدفق مستند جديد"],
|
||||||
"faq": [
|
"faq": [
|
||||||
{"q": "كيف أعيد ترتيب صفحات PDF؟", "a": "ارفع PDF وأدخل ترتيب الصفحات الجديد كأرقام مفصولة بفواصل (مثل 3,1,2,5,4). حمّل PDF المعاد ترتيبه."},
|
{"q": "كيف أعيد ترتيب صفحات PDF؟", "a": "ارفع PDF وأدخل ترتيب الصفحات الجديد كأرقام مفصولة بفواصل مع تضمين كل صفحة مرة واحدة بالضبط (مثل 3,1,2,5,4). ثم حمّل PDF المعاد ترتيبه."},
|
||||||
{"q": "هل يمكنني تكرار الصفحات في الترتيب الجديد؟", "a": "عادةً تحدد كل رقم صفحة مرة واحدة بالترتيب المطلوب."},
|
{"q": "هل يمكنني تكرار الصفحات في الترتيب الجديد؟", "a": "لا. الأداة تتطلب permutation كاملة، لذلك يجب أن يظهر كل رقم صفحة مرة واحدة فقط."},
|
||||||
{"q": "ماذا يحدث إذا تخطيت رقم صفحة؟", "a": "الصفحات المتخطاة لن تظهر في المخرجات، مما يزيلها فعلياً من المستند."}
|
{"q": "ماذا يحدث إذا تخطيت رقم صفحة؟", "a": "ستطلب منك الأداة إكمال الترتيب قبل المعالجة حتى لا تُحذف أي صفحة بالخطأ."}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"extractPages": {
|
"extractPages": {
|
||||||
|
|||||||
@@ -636,16 +636,24 @@
|
|||||||
},
|
},
|
||||||
"removeWatermark": {
|
"removeWatermark": {
|
||||||
"title": "Remove Watermark",
|
"title": "Remove Watermark",
|
||||||
"description": "Remove text watermarks from PDF files automatically.",
|
"description": "Remove supported text and image overlay watermarks from PDF files automatically.",
|
||||||
"shortDesc": "Remove Watermark"
|
"shortDesc": "Remove Watermark"
|
||||||
},
|
},
|
||||||
"reorderPdf": {
|
"reorderPdf": {
|
||||||
"title": "Reorder PDF Pages",
|
"title": "Reorder PDF Pages",
|
||||||
"description": "Rearrange the pages of your PDF in any order you want.",
|
"description": "Rearrange the pages of your PDF in any order you want while keeping every page exactly once.",
|
||||||
"shortDesc": "Reorder Pages",
|
"shortDesc": "Reorder Pages",
|
||||||
"orderLabel": "New Page Order",
|
"orderLabel": "New Page Order",
|
||||||
"orderPlaceholder": "e.g. 3,1,2,5,4",
|
"orderPlaceholder": "e.g. 3,1,2,5,4",
|
||||||
"orderHint": "Enter page numbers separated by commas in the desired order."
|
"orderHint": "Enter every page number exactly once, separated by commas, in the desired order.",
|
||||||
|
"readingPageCount": "Reading total page count...",
|
||||||
|
"pageCount": "Detected pages: {{count}}",
|
||||||
|
"pageCountFailed": "Couldn't read the page count from this PDF. Please choose another file.",
|
||||||
|
"orderInvalidFormat": "Use comma-separated page numbers only, for example 3,1,2.",
|
||||||
|
"orderMustIncludeAllPages": "Include every page exactly once from 1 to {{count}}.",
|
||||||
|
"orderOutOfRange": "Out-of-range pages: {{pages}}.",
|
||||||
|
"orderMissingPages": "Missing pages: {{pages}}.",
|
||||||
|
"orderDuplicatePages": "Duplicate pages: {{pages}}."
|
||||||
},
|
},
|
||||||
"extractPages": {
|
"extractPages": {
|
||||||
"title": "Extract PDF Pages",
|
"title": "Extract PDF Pages",
|
||||||
@@ -883,12 +891,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"removeWatermark": {
|
"removeWatermark": {
|
||||||
"whatItDoes": "Remove text watermarks from PDF files automatically. The tool detects and removes watermark text overlays while preserving the rest of the document content and layout.",
|
"whatItDoes": "Remove supported text and image overlay watermarks from PDF files automatically. The tool detects removable overlay blocks while preserving the rest of the document content and layout.",
|
||||||
"howToUse": ["Upload your watermarked PDF.", "The tool automatically detects text watermarks.", "Click remove to process the document.", "Download the clean PDF without watermarks."],
|
"howToUse": ["Upload your watermarked PDF.", "The tool automatically detects supported text or image overlay watermarks.", "Click remove to process the document.", "Download the clean PDF without watermarks."],
|
||||||
"benefits": ["Automatic watermark detection", "Preserves document content and layout", "Works with text-based watermarks", "Free and secure", "No software installation required"],
|
"benefits": ["Automatic watermark detection", "Preserves document content and layout", "Works with supported text and image overlays", "Free and secure", "No software installation required"],
|
||||||
"useCases": ["Cleaning up draft versions for final distribution", "Removing outdated branding from documents", "Preparing clean versions for archival", "Removing sample/trial watermarks from purchased PDFs", "Cleaning scanned documents with unwanted overlays"],
|
"useCases": ["Cleaning up draft versions for final distribution", "Removing outdated branding from documents", "Preparing clean versions for archival", "Removing sample/trial watermarks from purchased PDFs", "Cleaning scanned documents with unwanted overlays"],
|
||||||
"faq": [
|
"faq": [
|
||||||
{"q": "Can this tool remove any watermark?", "a": "The tool works best with text-based watermarks. Image-based or deeply embedded watermarks may require additional processing."},
|
{"q": "Can this tool remove any watermark?", "a": "The tool works best with supported text and image overlay watermarks. Deeply embedded or flattened watermarks may still require additional processing."},
|
||||||
{"q": "Will removing the watermark affect document quality?", "a": "No, only the watermark text is removed. All other content remains intact."},
|
{"q": "Will removing the watermark affect document quality?", "a": "No, only the watermark text is removed. All other content remains intact."},
|
||||||
{"q": "Is it legal to remove watermarks?", "a": "Only remove watermarks from documents you own or have permission to modify. Respect copyright and intellectual property rights."}
|
{"q": "Is it legal to remove watermarks?", "a": "Only remove watermarks from documents you own or have permission to modify. Respect copyright and intellectual property rights."}
|
||||||
]
|
]
|
||||||
@@ -927,14 +935,14 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"reorderPdf": {
|
"reorderPdf": {
|
||||||
"whatItDoes": "Rearrange the pages of your PDF document in any order. Specify a custom page order to reorganize your document without splitting and re-merging. Quick and easy page reorganization.",
|
"whatItDoes": "Rearrange the pages of your PDF document in any order. Specify a full page permutation to reorganize your document without splitting and re-merging.",
|
||||||
"howToUse": ["Upload your PDF document.", "Enter the new page order (e.g. 3,1,2,5,4).", "Click reorder to rearrange the pages.", "Download the reordered PDF."],
|
"howToUse": ["Upload your PDF document.", "Enter the new page order with every page exactly once (e.g. 3,1,2,5,4).", "Click reorder to rearrange the pages.", "Download the reordered PDF."],
|
||||||
"benefits": ["Custom page ordering with simple syntax", "Reorganize without splitting and merging", "Works on any PDF document", "Free and fast processing", "No quality loss"],
|
"benefits": ["Custom page ordering with simple syntax", "Prevents accidental page loss or duplication", "Works on any PDF document", "Free and fast processing", "No quality loss"],
|
||||||
"useCases": ["Moving an appendix to the front of a document", "Reorganizing a presentation for a different audience", "Fixing page order in scanned documents", "Creating custom document arrangements from existing PDFs", "Rearranging sections for a new document flow"],
|
"useCases": ["Moving an appendix to the front of a document", "Reorganizing a presentation for a different audience", "Fixing page order in scanned documents", "Creating custom document arrangements from existing PDFs", "Rearranging sections for a new document flow"],
|
||||||
"faq": [
|
"faq": [
|
||||||
{"q": "How do I reorder PDF pages?", "a": "Upload your PDF and enter the new page order as comma-separated numbers (e.g. 3,1,2,5,4). Download the reordered PDF."},
|
{"q": "How do I reorder PDF pages?", "a": "Upload your PDF and enter the new page order as comma-separated numbers, including every page exactly once (e.g. 3,1,2,5,4). Download the reordered PDF."},
|
||||||
{"q": "Can I duplicate pages in the new order?", "a": "Typically, you specify each page number once in your desired order."},
|
{"q": "Can I duplicate pages in the new order?", "a": "No. The tool expects a full permutation, so each page number must appear exactly once."},
|
||||||
{"q": "What happens if I skip a page number?", "a": "Skipped pages will not appear in the output, effectively removing them from the document."}
|
{"q": "What happens if I skip a page number?", "a": "The tool will ask you to complete the order before processing so no pages are accidentally dropped."}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"extractPages": {
|
"extractPages": {
|
||||||
|
|||||||
@@ -636,16 +636,24 @@
|
|||||||
},
|
},
|
||||||
"removeWatermark": {
|
"removeWatermark": {
|
||||||
"title": "Supprimer le filigrane",
|
"title": "Supprimer le filigrane",
|
||||||
"description": "Supprimez automatiquement les filigranes textuels des fichiers PDF.",
|
"description": "Supprimez automatiquement les filigranes textuels et les superpositions d'image prises en charge des fichiers PDF.",
|
||||||
"shortDesc": "Supprimer le filigrane"
|
"shortDesc": "Supprimer le filigrane"
|
||||||
},
|
},
|
||||||
"reorderPdf": {
|
"reorderPdf": {
|
||||||
"title": "Réorganiser les pages PDF",
|
"title": "Réorganiser les pages PDF",
|
||||||
"description": "Réorganisez les pages de votre PDF dans l'ordre souhaité.",
|
"description": "Réorganisez les pages de votre PDF dans l'ordre souhaité en conservant chaque page exactement une fois.",
|
||||||
"shortDesc": "Réorganiser les pages",
|
"shortDesc": "Réorganiser les pages",
|
||||||
"orderLabel": "Nouvel ordre des pages",
|
"orderLabel": "Nouvel ordre des pages",
|
||||||
"orderPlaceholder": "ex. 3,1,2,5,4",
|
"orderPlaceholder": "ex. 3,1,2,5,4",
|
||||||
"orderHint": "Entrez les numéros de pages séparés par des virgules dans l'ordre souhaité."
|
"orderHint": "Entrez chaque numéro de page exactement une fois, séparé par des virgules, dans l'ordre souhaité.",
|
||||||
|
"readingPageCount": "Lecture du nombre total de pages...",
|
||||||
|
"pageCount": "Pages détectées : {{count}}",
|
||||||
|
"pageCountFailed": "Impossible de lire le nombre de pages de ce PDF. Veuillez choisir un autre fichier.",
|
||||||
|
"orderInvalidFormat": "Utilisez uniquement des numéros de page séparés par des virgules, par exemple 3,1,2.",
|
||||||
|
"orderMustIncludeAllPages": "Incluez chaque page exactement une fois de 1 à {{count}}.",
|
||||||
|
"orderOutOfRange": "Pages hors plage : {{pages}}.",
|
||||||
|
"orderMissingPages": "Pages manquantes : {{pages}}.",
|
||||||
|
"orderDuplicatePages": "Pages en double : {{pages}}."
|
||||||
},
|
},
|
||||||
"extractPages": {
|
"extractPages": {
|
||||||
"title": "Extraire des pages PDF",
|
"title": "Extraire des pages PDF",
|
||||||
@@ -883,12 +891,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"removeWatermark": {
|
"removeWatermark": {
|
||||||
"whatItDoes": "Supprimez automatiquement les filigranes textuels des fichiers PDF. L'outil détecte et supprime les couches de texte en filigrane tout en préservant le contenu et la mise en page du reste du document.",
|
"whatItDoes": "Supprimez automatiquement les filigranes textuels et les superpositions d'image prises en charge des fichiers PDF. L'outil détecte les blocs de filigrane amovibles tout en préservant le contenu et la mise en page du reste du document.",
|
||||||
"howToUse": ["Téléchargez votre fichier PDF avec filigrane.", "L'outil détecte automatiquement les filigranes textuels.", "Cliquez sur Supprimer pour traiter le document.", "Téléchargez le PDF propre sans filigrane."],
|
"howToUse": ["Téléchargez votre fichier PDF avec filigrane.", "L'outil détecte automatiquement les filigranes textuels ou les superpositions d'image prises en charge.", "Cliquez sur Supprimer pour traiter le document.", "Téléchargez le PDF propre sans filigrane."],
|
||||||
"benefits": ["Détection automatique des filigranes", "Préserve le contenu et la mise en page du document", "Fonctionne avec les filigranes textuels", "Gratuit et sécurisé", "Aucune installation requise"],
|
"benefits": ["Détection automatique des filigranes", "Préserve le contenu et la mise en page du document", "Fonctionne avec les superpositions textuelles et d'image prises en charge", "Gratuit et sécurisé", "Aucune installation requise"],
|
||||||
"useCases": ["Nettoyer les versions brouillon pour la distribution finale", "Supprimer l'ancien branding des documents", "Préparer des copies propres pour l'archivage", "Supprimer les marques d'échantillon/d'essai des PDF achetés", "Nettoyer les documents numérisés des couches indésirables"],
|
"useCases": ["Nettoyer les versions brouillon pour la distribution finale", "Supprimer l'ancien branding des documents", "Préparer des copies propres pour l'archivage", "Supprimer les marques d'échantillon/d'essai des PDF achetés", "Nettoyer les documents numérisés des couches indésirables"],
|
||||||
"faq": [
|
"faq": [
|
||||||
{"q": "Cet outil peut-il supprimer tout type de filigrane ?", "a": "L'outil fonctionne mieux avec les filigranes textuels. Les filigranes basés sur des images peuvent nécessiter un traitement supplémentaire."},
|
{"q": "Cet outil peut-il supprimer tout type de filigrane ?", "a": "L'outil fonctionne mieux avec les filigranes textuels et les superpositions d'image prises en charge. Les filigranes profondément intégrés ou aplatis peuvent nécessiter un traitement supplémentaire."},
|
||||||
{"q": "La suppression du filigrane affectera-t-elle la qualité du document ?", "a": "Non, seul le texte du filigrane est supprimé. Tout le reste du contenu reste intact."},
|
{"q": "La suppression du filigrane affectera-t-elle la qualité du document ?", "a": "Non, seul le texte du filigrane est supprimé. Tout le reste du contenu reste intact."},
|
||||||
{"q": "Est-il légal de supprimer des filigranes ?", "a": "Ne supprimez les filigranes que des documents que vous possédez ou pour lesquels vous avez l'autorisation de modification. Respectez les droits d'auteur et la propriété intellectuelle."}
|
{"q": "Est-il légal de supprimer des filigranes ?", "a": "Ne supprimez les filigranes que des documents que vous possédez ou pour lesquels vous avez l'autorisation de modification. Respectez les droits d'auteur et la propriété intellectuelle."}
|
||||||
]
|
]
|
||||||
@@ -927,14 +935,14 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"reorderPdf": {
|
"reorderPdf": {
|
||||||
"whatItDoes": "Réorganisez les pages d'un document PDF dans n'importe quel ordre. Spécifiez un ordre de pages personnalisé pour réorganiser votre document sans avoir à diviser et refusionner.",
|
"whatItDoes": "Réorganisez les pages d'un document PDF dans n'importe quel ordre. Spécifiez une permutation complète des pages pour réorganiser votre document sans avoir à diviser et refusionner.",
|
||||||
"howToUse": ["Téléchargez votre document PDF.", "Saisissez le nouvel ordre des pages (ex. 3,1,2,5,4).", "Cliquez sur Réorganiser pour ordonner les pages.", "Téléchargez le PDF réorganisé."],
|
"howToUse": ["Téléchargez votre document PDF.", "Saisissez le nouvel ordre des pages en incluant chaque page exactement une fois (ex. 3,1,2,5,4).", "Cliquez sur Réorganiser pour ordonner les pages.", "Téléchargez le PDF réorganisé."],
|
||||||
"benefits": ["Ordre de pages personnalisé avec une syntaxe simple", "Réorganisation sans division et fusion", "Fonctionne avec tout document PDF", "Gratuit avec traitement rapide", "Sans perte de qualité"],
|
"benefits": ["Ordre de pages personnalisé avec une syntaxe simple", "Évite la perte ou la duplication accidentelle de pages", "Fonctionne avec tout document PDF", "Gratuit avec traitement rapide", "Sans perte de qualité"],
|
||||||
"useCases": ["Déplacer une annexe au début du document", "Réorganiser une présentation pour un public différent", "Corriger l'ordre des pages dans les documents numérisés", "Créer des arrangements personnalisés à partir de PDF existants", "Réordonner des sections pour un nouveau flux de document"],
|
"useCases": ["Déplacer une annexe au début du document", "Réorganiser une présentation pour un public différent", "Corriger l'ordre des pages dans les documents numérisés", "Créer des arrangements personnalisés à partir de PDF existants", "Réordonner des sections pour un nouveau flux de document"],
|
||||||
"faq": [
|
"faq": [
|
||||||
{"q": "Comment réorganiser les pages d'un PDF ?", "a": "Téléchargez votre PDF et saisissez le nouvel ordre des pages sous forme de numéros séparés par des virgules (ex. 3,1,2,5,4). Téléchargez le PDF réorganisé."},
|
{"q": "Comment réorganiser les pages d'un PDF ?", "a": "Téléchargez votre PDF et saisissez le nouvel ordre des pages sous forme de numéros séparés par des virgules, en incluant chaque page exactement une fois (ex. 3,1,2,5,4). Téléchargez le PDF réorganisé."},
|
||||||
{"q": "Puis-je dupliquer des pages dans le nouvel ordre ?", "a": "En général, vous spécifiez chaque numéro de page une fois dans l'ordre souhaité."},
|
{"q": "Puis-je dupliquer des pages dans le nouvel ordre ?", "a": "Non. L'outil attend une permutation complète, donc chaque numéro de page doit apparaître exactement une fois."},
|
||||||
{"q": "Que se passe-t-il si j'omets un numéro de page ?", "a": "Les pages omises n'apparaîtront pas dans la sortie, les supprimant effectivement du document."}
|
{"q": "Que se passe-t-il si j'omets un numéro de page ?", "a": "L'outil vous demandera de compléter l'ordre avant le traitement afin qu'aucune page ne soit supprimée par erreur."}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"extractPages": {
|
"extractPages": {
|
||||||
|
|||||||
Reference in New Issue
Block a user