photo scan V2 (#5255)

# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Anthony Stirling
2025-12-30 18:55:56 +00:00
committed by GitHub
parent 8f1af5f967
commit 70fc6348f3
38 changed files with 4394 additions and 2780 deletions

View File

@@ -963,6 +963,7 @@ desc = "Add custom text anywhere in your PDF"
addFiles = "Add Files"
uploadFromComputer = "Upload from computer"
openFromComputer = "Open from computer"
mobileUpload = "Upload from Mobile"
[viewPdf]
tags = "view,read,annotate,text,image,highlight,edit"
@@ -4360,6 +4361,13 @@ description = "Path to WeasyPrint executable for HTML to PDF conversion (leave e
label = "Unoconvert Executable"
description = "Path to LibreOffice unoconvert for document conversions (leave empty for default: /opt/venv/bin/unoconvert)"
[admin.settings.general.frontendUrl]
label = "Frontend URL"
description = "Base URL for frontend (e.g., https://pdf.example.com). Used for email invite links and mobile QR code uploads. Leave empty to use backend URL."
[admin.settings.badge]
clickToUpgrade = "Click to view plan details"
[admin.settings.security]
title = "Security"
description = "Configure authentication, login behaviour, and security policies."
@@ -4564,6 +4572,13 @@ description = "Automatically create user accounts on first SAML2 login"
label = "Block Registration"
description = "Prevent new user registration via SAML2"
[admin.settings.connections.mobileScanner]
label = "Mobile Phone Upload"
enable = "Enable QR Code Upload"
description = "Allow users to upload files from mobile devices by scanning a QR code"
note = "Note: Requires Frontend URL to be configured. "
link = "Configure in System Settings"
[admin.settings.database]
title = "Database"
description = "Configure custom database connection settings for enterprise deployments."
@@ -4745,6 +4760,10 @@ description = "Allow admins to invite users via email with auto-generated passwo
label = "Frontend URL"
description = "Base URL for frontend (e.g. https://pdf.example.com). Used for generating invite links in emails. Leave empty to use backend URL."
[admin.settings.mail.frontendUrlNote]
note = "Note: Requires Frontend URL to be configured. "
link = "Configure in System Settings"
[admin.settings.legal]
title = "Legal Documents"
description = "Configure links to legal documents and policies."
@@ -4930,6 +4949,8 @@ googleDriveShort = "Drive"
myFiles = "My Files"
noRecentFiles = "No recent files found"
googleDriveNotAvailable = "Google Drive integration not available"
mobileUpload = "Mobile Upload"
mobileShort = "Mobile"
downloadSelected = "Download Selected"
saveSelected = "Save Selected"
openFiles = "Open Files"
@@ -6399,3 +6420,56 @@ title = "Add Text Results"
[addText.error]
failed = "An error occurred while adding text to the PDF."
[mobileUpload]
title = "Upload from Mobile"
description = "Scan this QR code with your mobile device to upload photos directly to this page."
error = "Connection Error"
pollingError = "Error checking for files"
sessionId = "Session ID"
sessionCreateError = "Failed to create session"
expiryWarning = "Session Expiring Soon"
expiryWarningMessage = "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically."
filesReceived = "{{count}} file(s) received"
connected = "Mobile device connected"
instructions = "Open the camera app on your phone and scan this code. Files will be transferred directly between devices."
[mobileScanner]
title = "Mobile Scanner"
noSession = "Invalid Session"
noSessionMessage = "Please scan a valid QR code to access this page."
validating = "Validating session..."
sessionInvalid = "Session Error"
sessionExpired = "This session has expired. Please refresh and try again."
sessionNotFound = "Session not found. Please refresh and try again."
sessionValidationError = "Unable to verify session. Please try again."
uploadSuccess = "Upload Successful!"
uploadSuccessMessage = "Your images have been transferred."
httpsRequired = "Camera access requires HTTPS or localhost. Please use HTTPS or access via localhost."
uploadFailed = "Upload failed. Please try again."
uploading = "Uploading..."
connected = "Connected"
connecting = "Connecting..."
chooseMethod = "Choose Upload Method"
chooseMethodDescription = "Select how you want to scan and upload documents"
camera = "Camera"
cameraDescription = "Scan documents using your device camera with automatic edge detection"
fileUpload = "File Upload"
fileDescription = "Upload existing photos or documents from your device"
cameraAccessDenied = "Camera access denied. Please enable camera access."
back = "Back"
settings = "Settings"
edgeDetection = "Edge Detection"
flashlight = "Flashlight"
flash = "Flash"
processing = "Processing..."
capture = "Capture Photo"
selectFilesPrompt = "Select files to upload"
selectImage = "Select Image"
preview = "Preview"
retake = "Retake"
addToBatch = "Add to Batch"
upload = "Upload"
batchImages = "Batch"
clearBatch = "Clear"
uploadAll = "Upload All"

View File

@@ -0,0 +1,263 @@
/*! jscanify v1.4.0 | (c) ColonelParrot and other contributors | MIT License */
(function (global, factory) {
typeof exports === "object" && typeof module !== "undefined"
? (module.exports = factory())
: typeof define === "function" && define.amd
? define(factory)
: (global.jscanify = factory());
})(this, function () {
"use strict";
/**
* Calculates distance between two points. Each point must have `x` and `y` property
* @param {*} p1 point 1
* @param {*} p2 point 2
* @returns distance between two points
*/
function distance(p1, p2) {
return Math.hypot(p1.x - p2.x, p1.y - p2.y);
}
class jscanify {
constructor() { }
/**
* Finds the contour of the paper within the image
* @param {*} img image to process (cv.Mat)
* @returns the biggest contour inside the image
*/
findPaperContour(img) {
const imgGray = new cv.Mat();
cv.Canny(img, imgGray, 50, 200);
const imgBlur = new cv.Mat();
cv.GaussianBlur(
imgGray,
imgBlur,
new cv.Size(3, 3),
0,
0,
cv.BORDER_DEFAULT
);
const imgThresh = new cv.Mat();
cv.threshold(
imgBlur,
imgThresh,
0,
255,
cv.THRESH_OTSU
);
let contours = new cv.MatVector();
let hierarchy = new cv.Mat();
cv.findContours(
imgThresh,
contours,
hierarchy,
cv.RETR_CCOMP,
cv.CHAIN_APPROX_SIMPLE
);
let maxArea = 0;
let maxContourIndex = -1;
for (let i = 0; i < contours.size(); ++i) {
let contourArea = cv.contourArea(contours.get(i));
if (contourArea > maxArea) {
maxArea = contourArea;
maxContourIndex = i;
}
}
const maxContour =
maxContourIndex >= 0 ?
contours.get(maxContourIndex) :
null;
imgGray.delete();
imgBlur.delete();
imgThresh.delete();
contours.delete();
hierarchy.delete();
return maxContour;
}
/**
* Highlights the paper detected inside the image.
* @param {*} image image to process
* @param {*} options options for highlighting. Accepts `color` and `thickness` parameter
* @returns `HTMLCanvasElement` with original image and paper highlighted
*/
highlightPaper(image, options) {
options = options || {};
options.color = options.color || "orange";
options.thickness = options.thickness || 10;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = cv.imread(image);
const maxContour = this.findPaperContour(img);
cv.imshow(canvas, img);
if (maxContour) {
const {
topLeftCorner,
topRightCorner,
bottomLeftCorner,
bottomRightCorner,
} = this.getCornerPoints(maxContour, img);
if (
topLeftCorner &&
topRightCorner &&
bottomLeftCorner &&
bottomRightCorner
) {
ctx.strokeStyle = options.color;
ctx.lineWidth = options.thickness;
ctx.beginPath();
ctx.moveTo(...Object.values(topLeftCorner));
ctx.lineTo(...Object.values(topRightCorner));
ctx.lineTo(...Object.values(bottomRightCorner));
ctx.lineTo(...Object.values(bottomLeftCorner));
ctx.lineTo(...Object.values(topLeftCorner));
ctx.stroke();
}
}
img.delete();
return canvas;
}
/**
* Extracts and undistorts the image detected within the frame.
*
* Returns `null` if no paper is detected.
*
* @param {*} image image to process
* @param {*} resultWidth desired result paper width
* @param {*} resultHeight desired result paper height
* @param {*} cornerPoints optional custom corner points, in case automatic corner points are incorrect
* @returns `HTMLCanvasElement` containing undistorted image
*/
extractPaper(image, resultWidth, resultHeight, cornerPoints) {
const canvas = document.createElement("canvas");
const img = cv.imread(image);
const maxContour = cornerPoints ? null : this.findPaperContour(img);
if(maxContour == null && cornerPoints === undefined){
return null;
}
const {
topLeftCorner,
topRightCorner,
bottomLeftCorner,
bottomRightCorner,
} = cornerPoints || this.getCornerPoints(maxContour, img);
let warpedDst = new cv.Mat();
let dsize = new cv.Size(resultWidth, resultHeight);
let srcTri = cv.matFromArray(4, 1, cv.CV_32FC2, [
topLeftCorner.x,
topLeftCorner.y,
topRightCorner.x,
topRightCorner.y,
bottomLeftCorner.x,
bottomLeftCorner.y,
bottomRightCorner.x,
bottomRightCorner.y,
]);
let dstTri = cv.matFromArray(4, 1, cv.CV_32FC2, [
0,
0,
resultWidth,
0,
0,
resultHeight,
resultWidth,
resultHeight,
]);
let M = cv.getPerspectiveTransform(srcTri, dstTri);
cv.warpPerspective(
img,
warpedDst,
M,
dsize,
cv.INTER_LINEAR,
cv.BORDER_CONSTANT,
new cv.Scalar()
);
cv.imshow(canvas, warpedDst);
img.delete()
warpedDst.delete()
return canvas;
}
/**
* Calculates the corner points of a contour.
* @param {*} contour contour from {@link findPaperContour}
* @returns object with properties `topLeftCorner`, `topRightCorner`, `bottomLeftCorner`, `bottomRightCorner`, each with `x` and `y` property
*/
getCornerPoints(contour) {
let rect = cv.minAreaRect(contour);
const center = rect.center;
let topLeftCorner;
let topLeftCornerDist = 0;
let topRightCorner;
let topRightCornerDist = 0;
let bottomLeftCorner;
let bottomLeftCornerDist = 0;
let bottomRightCorner;
let bottomRightCornerDist = 0;
for (let i = 0; i < contour.data32S.length; i += 2) {
const point = { x: contour.data32S[i], y: contour.data32S[i + 1] };
const dist = distance(point, center);
if (point.x < center.x && point.y < center.y) {
// top left
if (dist > topLeftCornerDist) {
topLeftCorner = point;
topLeftCornerDist = dist;
}
} else if (point.x > center.x && point.y < center.y) {
// top right
if (dist > topRightCornerDist) {
topRightCorner = point;
topRightCornerDist = dist;
}
} else if (point.x < center.x && point.y > center.y) {
// bottom left
if (dist > bottomLeftCornerDist) {
bottomLeftCorner = point;
bottomLeftCornerDist = dist;
}
} else if (point.x > center.x && point.y > center.y) {
// bottom right
if (dist > bottomRightCornerDist) {
bottomRightCorner = point;
bottomRightCornerDist = dist;
}
}
}
return {
topLeftCorner,
topRightCorner,
bottomLeftCorner,
bottomRightCorner,
};
}
}
return jscanify;
});

File diff suppressed because one or more lines are too long