update signature validation

pull/475/head
Alex Turchyn 7 months ago committed by Pete Matsyburka
parent 7e70f4fb14
commit b0110d9340

@ -1,5 +1,6 @@
import SignaturePad from 'signature_pad' import SignaturePad from 'signature_pad'
import { cropCanvasAndExportToPNG } from './submission_form/crop_canvas' import { cropCanvasAndExportToPNG } from './submission_form/crop_canvas'
import { isValidSignatureCanvas } from './submission_form/validate_signature'
window.customElements.define('draw-signature', class extends HTMLElement { window.customElements.define('draw-signature', class extends HTMLElement {
connectedCallback () { connectedCallback () {
@ -43,6 +44,8 @@ window.customElements.define('draw-signature', class extends HTMLElement {
return response return response
}) })
}).catch(error => {
console.log(error)
}).finally(() => { }).finally(() => {
this.submitButton.disabled = false this.submitButton.disabled = false
}) })
@ -65,8 +68,13 @@ window.customElements.define('draw-signature', class extends HTMLElement {
} }
async submitImage () { async submitImage () {
return new Promise((resolve, reject) => { if (!isValidSignatureCanvas(this.pad.toData())) {
cropCanvasAndExportToPNG(this.canvas, { errorOnTooSmall: true }).then(async (blob) => { alert('Signature is too small or simple. Please redraw.')
return Promise.reject(new Error('Image too small or simple'))
}
return cropCanvasAndExportToPNG(this.canvas).then(async (blob) => {
const file = new File([blob], 'signature.png', { type: 'image/png' }) const file = new File([blob], 'signature.png', { type: 'image/png' })
const formData = new FormData() const formData = new FormData()
@ -79,12 +87,7 @@ window.customElements.define('draw-signature', class extends HTMLElement {
return fetch('/api/attachments', { return fetch('/api/attachments', {
method: 'POST', method: 'POST',
body: formData body: formData
}).then((resp) => resp.json()).then((attachment) => { }).then(resp => resp.json())
return resolve(attachment)
})
}).catch((error) => {
return reject(error)
})
}) })
} }

@ -1,4 +1,4 @@
function cropCanvasAndExportToPNG (canvas, { errorOnTooSmall } = { errorOnTooSmall: false }) { function cropCanvasAndExportToPNG (canvas) {
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
const width = canvas.width const width = canvas.width
@ -33,10 +33,6 @@ function cropCanvasAndExportToPNG (canvas, { errorOnTooSmall } = { errorOnTooSma
croppedCanvas.height = croppedHeight croppedCanvas.height = croppedHeight
const croppedCtx = croppedCanvas.getContext('2d') const croppedCtx = croppedCanvas.getContext('2d')
if (errorOnTooSmall && (croppedWidth < 20 || croppedHeight < 20)) {
return Promise.reject(new Error('Image too small'))
}
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight) croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

@ -1445,11 +1445,7 @@ export default {
this.isSubmittingComplete = false this.isSubmittingComplete = false
}) })
}).catch(error => { }).catch(error => {
if (error?.message === 'Image too small') {
alert(this.t('signature_is_too_small_please_redraw'))
} else {
console.log(error) console.log(error)
}
}).finally(() => { }).finally(() => {
this.isSubmitting = false this.isSubmitting = false
this.isSubmittingComplete = false this.isSubmittingComplete = false

@ -93,7 +93,7 @@ const en = {
reupload: 'Reupload', reupload: 'Reupload',
upload: 'Upload', upload: 'Upload',
files: 'Files', files: 'Files',
signature_is_too_small_please_redraw: 'Signature is too small. Please redraw.', signature_is_too_small_or_simple_please_redraw: 'Signature is too small or simple. Please redraw.',
wait_countdown_seconds: 'Wait {countdown} seconds' wait_countdown_seconds: 'Wait {countdown} seconds'
} }
@ -191,7 +191,7 @@ const es = {
reupload: 'Volver a subir', reupload: 'Volver a subir',
upload: 'Subir', upload: 'Subir',
files: 'Archivos', files: 'Archivos',
signature_is_too_small_please_redraw: 'La firma es demasiado pequeña. Por favor, dibújala de nuevo.', signature_is_too_small_or_simple_please_redraw: 'La firma es demasiado pequeña o simple. Por favor, vuelve a dibujarla.',
wait_countdown_seconds: 'Espera {countdown} segundos' wait_countdown_seconds: 'Espera {countdown} segundos'
} }
@ -289,7 +289,7 @@ const it = {
reupload: 'Ricarica', reupload: 'Ricarica',
upload: 'Carica', upload: 'Carica',
files: 'File', files: 'File',
signature_is_too_small_please_redraw: 'La firma è troppo piccola. Ridisegnala per favore.', signature_is_too_small_or_simple_please_redraw: 'La firma è troppo piccola o semplice. Ridisegnala, per favore.',
wait_countdown_seconds: 'Attendi {countdown} secondi' wait_countdown_seconds: 'Attendi {countdown} secondi'
} }
@ -387,7 +387,7 @@ const de = {
reupload: 'Erneut hochladen', reupload: 'Erneut hochladen',
upload: 'Hochladen', upload: 'Hochladen',
files: 'Dateien', files: 'Dateien',
signature_is_too_small_please_redraw: 'Die Unterschrift ist zu klein. Bitte erneut zeichnen.', signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte erneut zeichnen.',
wait_countdown_seconds: 'Warte {countdown} Sekunden' wait_countdown_seconds: 'Warte {countdown} Sekunden'
} }
@ -485,7 +485,7 @@ const fr = {
reupload: 'Recharger', reupload: 'Recharger',
upload: 'Télécharger', upload: 'Télécharger',
files: 'Fichiers', files: 'Fichiers',
signature_is_too_small_please_redraw: 'La signature est trop petite. Veuillez la redessiner.', signature_is_too_small_or_simple_please_redraw: 'La signature est trop petite ou trop simple. Veuillez la redessiner.',
wait_countdown_seconds: 'Attendez {countdown} secondes' wait_countdown_seconds: 'Attendez {countdown} secondes'
} }
@ -583,7 +583,7 @@ const pl = {
reupload: 'Ponowne przesłanie', reupload: 'Ponowne przesłanie',
upload: 'Przesyłanie', upload: 'Przesyłanie',
files: 'Pliki', files: 'Pliki',
signature_is_too_small_please_redraw: 'Podpis jest zbyt mały. Proszę narysować go ponownie.' signature_is_too_small_or_simple_please_redraw: 'Podpis jest zbyt mały lub zbyt prosty. Proszę narysować go ponownie.'
} }
const uk = { const uk = {
@ -680,7 +680,7 @@ const uk = {
reupload: 'Перезавантажити', reupload: 'Перезавантажити',
upload: 'Завантажити', upload: 'Завантажити',
files: 'Файли', files: 'Файли',
signature_is_too_small_please_redraw: 'Підпис занадто малий. Будь ласка, перемалюйте його.', signature_is_too_small_or_simple_please_redraw: 'Підпис занадто маленький або надто простий. Будь ласка, перемалюйте.',
wait_countdown_seconds: 'Зачекайте {countdown} секунд' wait_countdown_seconds: 'Зачекайте {countdown} секунд'
} }
@ -778,7 +778,7 @@ const cs = {
reupload: 'Znovu nahrát', reupload: 'Znovu nahrát',
upload: 'Nahrát', upload: 'Nahrát',
files: 'Soubory', files: 'Soubory',
signature_is_too_small_please_redraw: 'Podpis je příliš malý. Prosím, překreslete ho.', signature_is_too_small_or_simple_please_redraw: 'Podpis je příliš malý nebo jednoduchý. Nakreslete jej prosím znovu.',
wait_countdown_seconds: 'Počkejte {countdown} sekund' wait_countdown_seconds: 'Počkejte {countdown} sekund'
} }
@ -876,7 +876,7 @@ const pt = {
reupload: 'Reenviar', reupload: 'Reenviar',
upload: 'Carregar', upload: 'Carregar',
files: 'Arquivos', files: 'Arquivos',
signature_is_too_small_please_redraw: 'A assinatura é muito pequena. Por favor, redesenhe-a.', signature_is_too_small_or_simple_please_redraw: 'A assinatura é muito pequena ou simples. Por favor, redesenhe.',
wait_countdown_seconds: 'Aguarde {countdown} segundos' wait_countdown_seconds: 'Aguarde {countdown} segundos'
} }
@ -975,7 +975,7 @@ const he = {
reupload: 'העלה שוב', reupload: 'העלה שוב',
upload: 'העלאה', upload: 'העלאה',
files: 'קבצים', files: 'קבצים',
signature_is_too_small_please_redraw: 'החתימה קטנה מדי. אנא צייר מחדש.', signature_is_too_small_or_simple_please_redraw: 'החתימה קטנה או פשוטה מדי. אנא חתום מחדש.',
wait_countdown_seconds: 'המתן {countdown} שניות' wait_countdown_seconds: 'המתן {countdown} שניות'
} }
@ -1074,7 +1074,7 @@ const nl = {
reupload: 'Opnieuw uploaden', reupload: 'Opnieuw uploaden',
upload: 'Uploaden', upload: 'Uploaden',
files: 'Bestanden', files: 'Bestanden',
signature_is_too_small_please_redraw: 'De handtekening is te klein. Teken deze opnieuw, alstublieft.', signature_is_too_small_or_simple_please_redraw: 'De handtekening is te klein of te eenvoudig. Teken opnieuw.',
wait_countdown_seconds: 'Wacht {countdown} seconden' wait_countdown_seconds: 'Wacht {countdown} seconden'
} }
@ -1172,7 +1172,7 @@ const ar = {
reupload: 'إعادة التحميل', reupload: 'إعادة التحميل',
upload: 'تحميل', upload: 'تحميل',
files: 'الملفات', files: 'الملفات',
signature_is_too_small_please_redraw: 'التوقيع صغير جدًا. يرجى إعادة الرسم.', signature_is_too_small_or_simple_please_redraw: 'التوقيع صغير جدًا أو بسيط جدًا. يرجى إعادة رسمه.',
wait_countdown_seconds: 'انتظر {countdown} ثانية' wait_countdown_seconds: 'انتظر {countdown} ثانية'
} }
@ -1269,7 +1269,7 @@ const ko = {
reupload: '다시 업로드', reupload: '다시 업로드',
upload: '업로드', upload: '업로드',
files: '파일', files: '파일',
signature_is_too_small_please_redraw: '서명이 너무 작니다. 다시 그려주세요.', signature_is_too_small_or_simple_please_redraw: '서명이 너무 작거나 단순합니다. 다시 그려주세요.',
wait_countdown_seconds: '{countdown}초 기다리세요' wait_countdown_seconds: '{countdown}초 기다리세요'
} }

@ -118,11 +118,17 @@
:src="attachmentsIndex[modelValue || computedPreviousValue].url" :src="attachmentsIndex[modelValue || computedPreviousValue].url"
class="mx-auto bg-white border border-base-300 rounded max-h-44" class="mx-auto bg-white border border-base-300 rounded max-h-44"
> >
<div class="relative">
<div
v-if="!isDrawInitials"
class="absolute top-0 right-0 left-0 bottom-0"
/>
<canvas <canvas
v-show="!modelValue && !computedPreviousValue" v-show="!modelValue && !computedPreviousValue"
ref="canvas" ref="canvas"
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas" class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
/> />
</div>
<input <input
v-if="!isDrawInitials && !modelValue && !computedPreviousValue" v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
id="initials_text_input" id="initials_text_input"

@ -292,6 +292,7 @@
<script> <script>
import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2, IconQrcode, IconX } from '@tabler/icons-vue' import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2, IconQrcode, IconX } from '@tabler/icons-vue'
import { cropCanvasAndExportToPNG } from './crop_canvas' import { cropCanvasAndExportToPNG } from './crop_canvas'
import { isValidSignatureCanvas } from './validate_signature'
import SignaturePad from 'signature_pad' import SignaturePad from 'signature_pad'
import AppearsOn from './appears_on' import AppearsOn from './appears_on'
import FileDropzone from './dropzone' import FileDropzone from './dropzone'
@ -680,8 +681,14 @@ export default {
return Promise.resolve({}) return Promise.resolve({})
} }
return new Promise((resolve, reject) => { if (this.isSignatureStarted && this.pad.toData().length > 0 && !isValidSignatureCanvas(this.pad.toData())) {
cropCanvasAndExportToPNG(this.$refs.canvas, { errorOnTooSmall: true }).then(async (blob) => { alert(this.t('signature_is_too_small_or_simple_please_redraw'))
return Promise.reject(new Error('Image too small or simple'))
}
return new Promise((resolve) => {
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
const file = new File([blob], 'signature.png', { type: 'image/png' }) const file = new File([blob], 'signature.png', { type: 'image/png' })
if (this.dryRun) { if (this.dryRun) {
@ -717,12 +724,6 @@ export default {
return resolve(attachment) return resolve(attachment)
}) })
} }
}).catch((error) => {
if (error.message === 'Image too small' && this.field.required === false) {
return resolve({})
} else {
return reject(error)
}
}) })
}) })
} }

@ -0,0 +1,38 @@
function isValidSignatureCanvas (data) {
if (data.length === 0) return false
const strokes = data.filter(stroke => Array.isArray(stroke.points) && stroke.points.length > 2)
if (strokes.length === 0) return false
let skippedStraightLine = 0
const validStrokes = strokes.filter(stroke => {
const points = stroke.points
const first = points[0]
const last = points[points.length - 1]
const A = last.y - first.y
const B = first.x - last.x
const C = last.x * first.y - first.x * last.y
const lineLength = Math.sqrt(A * A + B * B)
const totalDeviation = points.reduce((sum, p) => {
const distanceToLine = Math.abs(A * p.x + B * p.y + C) / lineLength
return sum + distanceToLine
}, 0)
const avgDeviation = totalDeviation / points.length
if (avgDeviation < 3 && skippedStraightLine < 2) {
skippedStraightLine++
return false
}
return true
})
return validStrokes.length > 0
}
export { isValidSignatureCanvas }

@ -4,21 +4,49 @@ module SigningFormHelper
module_function module_function
def draw_canvas def draw_canvas
page.find('canvas').click([], { x: 150, y: 100 })
page.execute_script <<~JS page.execute_script <<~JS
const canvas = document.getElementsByTagName('canvas')[0]; const canvas = document.getElementsByTagName('canvas')[0];
const ctx = canvas.getContext('2d'); const rect = canvas.getBoundingClientRect();
ctx.beginPath(); const startX = rect.left + 50;
ctx.moveTo(150, 100); const startY = rect.top + 100;
ctx.lineTo(450, 100);
ctx.stroke();
ctx.beginPath(); const amplitude = 20;
ctx.moveTo(150, 100); const wavelength = 30;
ctx.lineTo(150, 150); const length = 300;
ctx.stroke();
function dispatchPointerEvent(type, x, y) {
const event = new PointerEvent(type, {
pointerId: 1,
pointerType: 'pen',
isPrimary: true,
clientX: x,
clientY: y,
bubbles: true,
pressure: 0.5
});
canvas.dispatchEvent(event);
}
dispatchPointerEvent('pointerdown', startX, startY);
let x = 0;
function drawStep() {
if (x > length) {
dispatchPointerEvent('pointerup', startX + x, startY);
return;
}
const y = startY + amplitude * Math.sin((x / wavelength) * 2 * Math.PI);
dispatchPointerEvent('pointermove', startX + x, y);
x += 5;
requestAnimationFrame(drawStep);
}
drawStep();
JS JS
sleep 0.5 sleep 0.5
end end

@ -360,6 +360,19 @@ RSpec.describe 'Signing Form', type: :system do
expect(field_value(submitter, 'Signature')).to be_present expect(field_value(submitter, 'Signature')).to be_present
end end
it 'shows an error message if the canvas is not drawn or too simple' do
visit submit_form_path(slug: submitter.slug)
find('#expand_form_button').click
page.find('canvas').click([], { x: 150, y: 100 })
alert_text = page.accept_alert do
click_button 'Sign and Complete'
end
expect(alert_text).to eq 'Signature is too small or simple. Please redraw.'
end
it 'completes the form if the canvas is typed' do it 'completes the form if the canvas is typed' do
visit submit_form_path(slug: submitter.slug) visit submit_form_path(slug: submitter.slug)

Loading…
Cancel
Save