mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-05-01 23:16:31 +02:00
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:
@@ -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"
|
||||
|
||||
263
frontend/public/vendor/jscanify/jscanify.js
vendored
Normal file
263
frontend/public/vendor/jscanify/jscanify.js
vendored
Normal 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;
|
||||
});
|
||||
48
frontend/public/vendor/jscanify/opencv.js
vendored
Normal file
48
frontend/public/vendor/jscanify/opencv.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user