add screen reader mode

pull/636/head
Pete Matsyburka 3 weeks ago
parent ee65a5693c
commit 7cdf263da1

@ -0,0 +1,37 @@
# frozen_string_literal: true
class SubmitFormMetadataController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def index
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return head :not_found if submitter.declined_at? ||
submitter.completed_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired? ||
submitter.submission.template&.archived_at? ||
submitter.account.archived_at? ||
!Submitters::AuthorizedForForm.call(submitter, current_user, request)
submission = submitter.submission
values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) }
schema = Submissions.filtered_conditions_schema(submission, values:, include_submitter_uuid: submitter.uuid)
documents = schema.filter_map do |item|
submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] }
end
ActiveRecord::Associations::Preloader.new(records: documents, associations: %i[blob record]).call
text_runs = documents.to_h do |document|
[
document.uuid,
DocumentMetadatas.find_or_create_for_document(document, account_id: document.record.account_id).text_runs
]
end
render json: { text_runs: }
end
end

@ -47,13 +47,13 @@ export default class extends HTMLElement {
this.classList.remove('hidden', '-translate-y-10', 'opacity-0') this.classList.remove('hidden', '-translate-y-10', 'opacity-0')
this.classList.add('translate-y-0', 'opacity-100') this.classList.add('translate-y-0', 'opacity-100')
this.querySelectorAll('[tabindex]').forEach((el) => { el.tabIndex = 0 }) this.inert = false
} }
hideButtons () { hideButtons () {
this.classList.remove('translate-y-0', 'opacity-100') this.classList.remove('translate-y-0', 'opacity-100')
this.classList.add('-translate-y-10', 'opacity-0') this.classList.add('-translate-y-10', 'opacity-0')
this.querySelectorAll('[tabindex]').forEach((el) => { el.tabIndex = -1 }) this.inert = true
setTimeout(() => { setTimeout(() => {
if (this.classList.contains('-translate-y-10')) { if (this.classList.contains('-translate-y-10')) {

@ -0,0 +1,434 @@
<template>
<template
v-for="(pages, docUuid) in textRuns"
:key="docUuid"
>
<template
v-for="(_, pageIndex) in pages"
:key="pageIndex"
>
<template
v-for="(pageElem, i) in [findPageElement(docUuid, pageIndex)]"
:key="i"
>
<Teleport
v-if="pageElem"
:to="pageElem"
>
<template
v-for="(item, index) in sortedItemsForPage(docUuid, pageIndex)"
:key="index"
>
<template v-if="item.type === 'text_group'">
<span
class="absolute overflow-hidden text-transparent select-none pointer-events-none"
:style="{ left: item.x * 100 + '%', top: item.y * 100 + '%', width: item.w * 100 + '%', height: item.h * 100 + '%' }"
>{{ item.text }}</span>
<span
v-for="(run, runIndex) in item.items"
:key="runIndex"
aria-hidden="true"
class="absolute overflow-hidden text-transparent"
:style="{ left: run.x * 100 + '%', top: run.y * 100 + '%', width: run.w * 100 + '%', height: run.h * 100 + '%', fontSize: run.font_size ? (run.font_size / 10) + 'cqmin' : undefined, textAlign: 'justify', textAlignLast: 'justify', textJustify: 'inter-character' }"
>{{ run.text }}</span>
</template>
<FieldArea
v-else-if="item.type === 'field_area'"
:ref="setAreaRef"
v-model="values[item.field.uuid]"
:values="values"
:field="item.field"
:area="item.area"
:submittable="true"
:page-width="1400"
:page-height="(1400.0 / pageElem.offsetWidth) * pageElem.offsetHeight"
:field-index="item.fieldIndex"
:is-inline-size="isInlineSize"
:scroll-padding="scrollPadding"
:submitter="submitter"
:with-field-placeholder="withFieldPlaceholder"
:with-signature-id="withSignatureId"
:is-active="currentStep === item.step"
:with-label="withLabel && !withFieldPlaceholder && item.step.length < 2"
:is-value-set="item.step.some((f) => f.uuid in values)"
:attachments-index="attachmentsIndex"
@click="[$emit('focus-step', item.stepIndex), maybeScrollOnClick(item.field, item.area)]"
/>
<FieldArea
v-else-if="item.type === 'readonly_field_area'"
:model-value="readonlyConditionalFieldValues[item.field.uuid]"
:values="readonlyConditionalFieldValues"
:field="item.field"
:area="item.area"
:submittable="false"
:page-width="1400"
:page-height="(1400.0 / pageElem.offsetWidth) * pageElem.offsetHeight"
:field-index="item.fieldIndex"
:is-inline-size="isInlineSize"
:submitter="submitter"
:attachments-index="attachmentsIndex"
/>
<FieldArea
v-else-if="item.type === 'formula_area' && isMathLoaded"
:model-value="calculateFormula(item.field)"
:is-inline-size="isInlineSize"
:field="item.field"
:area="item.area"
:submittable="false"
:field-index="item.fieldIndex"
/>
</template>
</Teleport>
</template>
</template>
</template>
</template>
<script>
import FieldArea from './area'
import FormulaAreas from './formula_areas'
import FieldAreas from './areas'
export default {
name: 'AccessibilityAreas',
components: {
FieldArea
},
inject: ['baseUrl', 't'],
props: {
submitterSlug: {
type: String,
required: true
},
filledFieldsIndex: {
type: Object,
required: false,
default: null
},
steps: {
type: Array,
required: false,
default: () => []
},
readonlyConditionalFields: {
type: Array,
required: false,
default: () => []
},
readonlyConditionalFieldValues: {
type: Object,
required: false,
default: () => ({})
},
formulaFields: {
type: Array,
required: false,
default: () => []
},
values: {
type: Object,
required: false,
default: () => ({})
},
readonlyValues: {
type: Object,
required: false,
default: () => ({})
},
submitter: {
type: Object,
required: true
},
currentStep: {
type: Array,
required: false,
default: () => []
},
withFieldPlaceholder: {
type: Boolean,
required: false,
default: false
},
withSignatureId: {
type: Boolean,
required: false,
default: false
},
withLabel: {
type: Boolean,
required: false,
default: true
},
scrollPadding: {
type: String,
required: false,
default: '-80px'
},
scrollEl: {
type: Object,
required: false,
default: null
},
attachmentsIndex: {
type: Object,
required: false,
default: () => ({})
},
fetchOptions: {
type: Object,
required: false,
default: () => ({})
}
},
emits: ['focus-step'],
data () {
return {
isMathLoaded: false,
math: null,
textRuns: {},
areaRefs: []
}
},
computed: {
fieldValuesIndex () {
return this.filledFieldsIndex || this.extractStaticValues()
},
isMobileContainer: FieldAreas.computed.isMobileContainer,
isInlineSize: FieldAreas.computed.isInlineSize,
fieldsUuidIndex () {
return this.formulaFields.reduce((acc, field) => {
acc[field.uuid] = field
return acc
}, {})
},
fieldAreasIndex () {
const index = Object.create(null)
this.steps.forEach((step, stepIndex) => {
step.forEach((field, fieldIndex) => {
(field.areas || []).forEach((area) => {
index[area.attachment_uuid] ||= Object.create(null)
index[area.attachment_uuid][area.page] ||= []
index[area.attachment_uuid][area.page].push({
type: 'field_area',
field,
area,
step,
stepIndex,
fieldIndex,
x: area.x,
y: area.y,
w: area.w,
h: area.h
})
})
})
})
return index
},
formulaAreasIndex () {
const index = Object.create(null)
this.formulaFields.forEach((field, fieldIndex) => {
(field.areas || []).forEach((area) => {
index[area.attachment_uuid] ||= Object.create(null)
index[area.attachment_uuid][area.page] ||= []
index[area.attachment_uuid][area.page].push({
type: 'formula_area',
field,
area,
fieldIndex,
x: area.x,
y: area.y,
w: area.w,
h: area.h
})
})
})
return index
},
readonlyFieldAreasIndex () {
const index = Object.create(null)
this.readonlyConditionalFields.forEach((field, fieldIndex) => {
(field.areas || []).forEach((area) => {
index[area.attachment_uuid] ||= Object.create(null)
index[area.attachment_uuid][area.page] ||= []
index[area.attachment_uuid][area.page].push({
type: 'readonly_field_area',
field,
area,
fieldIndex,
x: area.x,
y: area.y,
w: area.w,
h: area.h
})
})
})
return index
}
},
beforeUpdate () {
this.areaRefs = []
},
async mounted () {
const [metadataResult] = await Promise.all([
fetch(this.baseUrl + `/s/${this.submitterSlug}/metadata`, {
...this.fetchOptions
}).then((r) => r.json()).catch(() => ({})),
this.loadMath()
])
this.textRuns = metadataResult.text_runs || {}
},
methods: {
normalizeFormula: FormulaAreas.methods.normalizeFormula,
calculateFormula: FormulaAreas.methods.calculateFormula,
scrollInContainer: FieldAreas.methods.scrollInContainer,
scrollIntoArea: FieldAreas.methods.scrollIntoArea,
scrollIntoField: FieldAreas.methods.scrollIntoField,
maybeScrollOnClick: FieldAreas.methods.maybeScrollOnClick,
setAreaRef (el) {
if (el) {
this.areaRefs.push(el)
}
},
async loadMath () {
if (this.formulaFields.length && !this.isMathLoaded) {
const { Calculator } = await import('./calculator')
this.math = new Calculator()
this.isMathLoaded = true
}
},
extractStaticValues () {
const result = Object.create(null)
const root = this.$root.$el?.parentNode?.getRootNode() || document
const pageContainers = root.querySelectorAll('page-container')
pageContainers.forEach((container) => {
const overlay = container.querySelector('[id^="page-"]')
if (!overlay) return
const parts = overlay.id.split('-')
const pageIndex = parseInt(parts[parts.length - 1])
const docUuid = parts.slice(1, -1).join('-')
const fieldValues = overlay.querySelectorAll('field-value')
if (!fieldValues.length) return
result[docUuid] ||= Object.create(null)
result[docUuid][pageIndex] = []
fieldValues.forEach((el) => {
const style = el.style
const x = parseFloat(style.left) / 100
const y = parseFloat(style.top) / 100
const w = parseFloat(style.width) / 100
const h = parseFloat(style.height) / 100
const text = el.textContent.trim()
if (text) {
result[docUuid][pageIndex].push({ type: 'static_value', text, x, y, w, h })
}
})
})
return result
},
findPageElement (docUuid, pageIndex) {
return (this.$root.$el?.parentNode?.getRootNode() || document).getElementById(`page-${docUuid}-${pageIndex}`)
},
sortedItemsForPage (docUuid, pageIndex) {
const items = []
const pageTextRuns = this.textRuns[docUuid]?.[pageIndex] || []
pageTextRuns.forEach((run) => {
items.push({
type: 'text_run',
text: run.text,
x: run.x,
y: run.y,
w: run.w,
h: run.h,
font_size: run.font_size
})
})
const fieldAreas = this.fieldAreasIndex[docUuid]?.[pageIndex] || []
items.push(...fieldAreas)
const readonlyFieldAreas = this.readonlyFieldAreasIndex[docUuid]?.[pageIndex] || []
items.push(...readonlyFieldAreas)
const formulaAreas = this.formulaAreasIndex[docUuid]?.[pageIndex] || []
items.push(...formulaAreas)
const pageFieldValues = this.fieldValuesIndex[docUuid]?.[pageIndex] || []
items.push(...pageFieldValues)
items.sort((a, b) => {
const aCenterY = a.y + a.h / 2
const bCenterY = b.y + b.h / 2
const lineThreshold = Math.min(a.h, b.h) / 2
if (Math.abs(aCenterY - bCenterY) < lineThreshold) {
return a.x - b.x
}
return aCenterY - bCenterY
})
const grouped = []
let currentGroup = null
const closeGroup = () => {
if (!currentGroup) return
const groupItems = currentGroup.items
const minX = Math.min(...groupItems.map((i) => i.x))
const minY = Math.min(...groupItems.map((i) => i.y))
const maxEndX = Math.max(...groupItems.map((i) => i.x + i.w))
const maxEndY = Math.max(...groupItems.map((i) => i.y + i.h))
currentGroup.x = minX
currentGroup.y = minY
currentGroup.w = maxEndX - minX
currentGroup.h = maxEndY - minY
currentGroup.text = groupItems.map((i) => i.text).join(' ').replace(/\s+/g, ' ').trim()
grouped.push(currentGroup)
currentGroup = null
}
for (const item of items) {
const isTextLike = item.type === 'text_run' || item.type === 'static_value'
if (!isTextLike) {
closeGroup()
grouped.push(item)
continue
}
if (!currentGroup) {
currentGroup = { type: 'text_group', items: [] }
}
currentGroup.items.push(item)
}
closeGroup()
return grouped
}
}
}
</script>

@ -4,11 +4,11 @@
dir="auto" dir="auto"
:style="[computedStyle, fontStyle]" :style="[computedStyle, fontStyle]"
:class="{ 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'outline-red-500 outline-dashed outline-2 z-10 field-area-active': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }" :class="{ 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'outline-red-500 outline-dashed outline-2 z-10 field-area-active': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
:role="submittable && field.type !== 'checkbox' && field.type !== 'radio' && field.type !== 'multiple' ? 'button' : undefined" :role="submittable && !isNativeInputField ? 'button' : undefined"
:tabindex="submittable ? 0 : undefined" :tabindex="submittable && !isNativeInputField ? 0 : undefined"
:aria-label="submittable ? fieldAreaLabel : undefined" :aria-label="submittable && !isNativeInputField ? fieldAreaLabel : undefined"
@keydown.enter.prevent="submittable ? $el.click() : undefined" @keydown.enter.prevent="submittable && !isNativeInputField ? $el.click() : undefined"
@keydown.space.prevent="submittable ? $el.click() : undefined" @keydown.space.prevent="submittable && !isNativeInputField ? $el.click() : undefined"
> >
<div <div
v-if="(!withFieldPlaceholder || !field.name || field.type === 'cells') && !isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid" v-if="(!withFieldPlaceholder || !field.name || field.type === 'cells') && !isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid"
@ -23,6 +23,7 @@
width="100%" width="100%"
height="100%" height="100%"
class="max-h-10 text-base-content" class="max-h-10 text-base-content"
aria-hidden="true"
/> />
</span> </span>
</div> </div>
@ -155,6 +156,7 @@
v-if="submittable" v-if="submittable"
type="radio" type="radio"
:value="false" :value="false"
:name="`radio-area-${field.uuid}`"
:aria-label="optionValue(option)" :aria-label="optionValue(option)"
class="aspect-square checked:checkbox checked:checkbox-xs" class="aspect-square checked:checkbox checked:checkbox-xs"
:class="{ 'base-radio': !modelValue || modelValue !== optionValue(option), '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }" :class="{ 'base-radio': !modelValue || modelValue !== optionValue(option), '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
@ -408,6 +410,9 @@ export default {
kba: this.t('kba') kba: this.t('kba')
} }
}, },
isNativeInputField () {
return ['checkbox', 'radio', 'multiple'].includes(this.field.type)
},
fieldAreaLabel () { fieldAreaLabel () {
const name = this.field.name || this.fieldNames[this.field.type] || this.field.type const name = this.field.name || this.fieldNames[this.field.type] || this.field.type
if (this.area.option_uuid && this.option) { if (this.area.option_uuid && this.option) {

@ -1,6 +1,10 @@
<template> <template>
<div> <div>
<ul v-if="value.length" :aria-label="t('uploaded_files')" class="list-none p-0 m-0"> <ul
v-if="value.length"
:aria-label="t('uploaded_files')"
class="list-none p-0 m-0"
>
<li <li
v-for="(val, index) in value" v-for="(val, index) in value"
:key="index" :key="index"
@ -20,7 +24,7 @@
<IconPaperclip <IconPaperclip
:width="16" :width="16"
class="flex-none" class="flex-none"
:heigh="16" :height="16"
aria-hidden="true" aria-hidden="true"
/> />
<span> <span>
@ -35,7 +39,7 @@
> >
<IconTrashX <IconTrashX
:width="18" :width="18"
:heigh="19" :height="19"
aria-hidden="true" aria-hidden="true"
/> />
</button> </button>

@ -4,12 +4,12 @@
class="mx-auto max-w-md flex flex-col completed-form" class="mx-auto max-w-md flex flex-col completed-form"
dir="auto" dir="auto"
role="status" role="status"
aria-live="assertive"
tabindex="-1" tabindex="-1"
> >
<div class="font-medium text-2xl flex items-center space-x-1.5 mx-auto"> <div class="font-medium text-2xl flex items-center space-x-1.5 mx-auto">
<IconCircleCheck <IconCircleCheck
class="inline text-green-600" class="inline text-green-600"
aria-hidden="true"
:width="30" :width="30"
:height="30" :height="30"
/> />
@ -47,7 +47,10 @@
class="animate-spin" class="animate-spin"
aria-hidden="true" aria-hidden="true"
/> />
<IconMail v-else /> <IconMail
v-else
aria-hidden="true"
/>
<span> <span>
{{ t('send_copy_via_email') }} {{ t('send_copy_via_email') }}
</span> </span>
@ -63,7 +66,10 @@
class="animate-spin" class="animate-spin"
aria-hidden="true" aria-hidden="true"
/> />
<IconDownload v-else /> <IconDownload
v-else
aria-hidden="true"
/>
<span> <span>
{{ t('download') }} {{ t('download') }}
</span> </span>

@ -30,7 +30,10 @@
class="btn btn-outline btn-sm !normal-case font-normal set-current-date-button" class="btn btn-outline btn-sm !normal-case font-normal set-current-date-button"
@click.prevent="[setCurrentDate(), $emit('focus')]" @click.prevent="[setCurrentDate(), $emit('focus')]"
> >
<IconCalendarCheck :width="16" aria-hidden="true" /> <IconCalendarCheck
:width="16"
aria-hidden="true"
/>
{{ t('set_today') }} {{ t('set_today') }}
</button> </button>
</div> </div>

@ -1,5 +1,52 @@
<template> <template>
<Teleport
v-if="withAccessibilityAreas === null && !isAccessibilityMode"
to="#sr_only_content"
>
<button
class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-base-100 focus:text-base-content focus:rounded focus:shadow-lg"
@click="isAccessibilityMode = true"
>
{{ t('enter_screen_reader_mode') }}
</button>
</Teleport>
<Teleport
v-for="item in (withAccessibilityAreas === null ? schema : [])"
:key="item.attachment_uuid"
:to="`#document-${item.attachment_uuid} .sr_only_content`"
>
<button
v-if="!isAccessibilityMode"
class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-base-100 focus:text-base-content focus:rounded focus:shadow-lg"
@click="isAccessibilityMode = true"
>
{{ t('enter_screen_reader_mode') }}
</button>
</Teleport>
<AccessibilityAreas
v-if="withAccessibilityAreas || isAccessibilityMode"
ref="areas"
:submitter-slug="submitterSlug"
:steps="stepFields"
:readonly-conditional-fields="readonlyConditionalFields"
:readonly-conditional-field-values="readonlyConditionalFieldValues"
:formula-fields="formulaFields"
:values="values"
:readonly-values="readonlyFieldValues"
:submitter="submitter"
:scroll-el="scrollEl"
:current-step="currentStepFields"
:with-field-placeholder="withFieldPlaceholder"
:with-signature-id="withSignatureId"
:with-label="withFieldLabels && !isAnonymousChecboxes && showFieldNames"
:scroll-padding="scrollPadding"
:attachments-index="attachmentsIndex"
:fetch-options="fetchOptions"
:filled-fields-index="filledFieldsIndex"
@focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]"
/>
<FieldAreas <FieldAreas
v-if="!withAccessibilityAreas && !isAccessibilityMode"
ref="areas" ref="areas"
:steps="stepFields" :steps="stepFields"
:values="values" :values="values"
@ -14,6 +61,7 @@
@focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]" @focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]"
/> />
<FieldAreas <FieldAreas
v-if="!withAccessibilityAreas && !isAccessibilityMode"
:steps="readonlyConditionalFields.map((e) => [e])" :steps="readonlyConditionalFields.map((e) => [e])"
:values="readonlyConditionalFieldValues" :values="readonlyConditionalFieldValues"
:submitter="submitter" :submitter="submitter"
@ -21,7 +69,7 @@
:submittable="false" :submittable="false"
/> />
<FormulaFieldAreas <FormulaFieldAreas
v-if="formulaFields.length" v-if="!withAccessibilityAreas && !isAccessibilityMode && formulaFields.length"
:fields="formulaFields" :fields="formulaFields"
:readonly-values="readonlyFieldValues" :readonly-values="readonlyFieldValues"
:values="values" :values="values"
@ -567,6 +615,7 @@
<nav <nav
v-if="stepFields.length < 80" v-if="stepFields.length < 80"
:aria-label="t('form_progress')" :aria-label="t('form_progress')"
:aria-hidden="isCompleted"
class="flex justify-center mt-3 sm:mt-4 mb-0 sm:mb-1 select-none" class="flex justify-center mt-3 sm:mt-4 mb-0 sm:mb-1 select-none"
> >
<div class="flex items-center flex-wrap steps-progress"> <div class="flex items-center flex-wrap steps-progress">
@ -597,6 +646,7 @@
<script> <script>
import FieldAreas from './areas' import FieldAreas from './areas'
import FormulaFieldAreas from './formula_areas' import FormulaFieldAreas from './formula_areas'
import AccessibilityAreas from './accessibility_areas'
import ImageStep from './image_step' import ImageStep from './image_step'
import SignatureStep from './signature_step' import SignatureStep from './signature_step'
import InitialsStep from './initials_step' import InitialsStep from './initials_step'
@ -655,6 +705,7 @@ export default {
name: 'SubmissionForm', name: 'SubmissionForm',
components: { components: {
FieldAreas, FieldAreas,
AccessibilityAreas,
ImageStep, ImageStep,
SignatureStep, SignatureStep,
AppearsOn, AppearsOn,
@ -837,6 +888,16 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
filledFieldsIndex: {
type: Object,
required: false,
default: null
},
withAccessibilityAreas: {
type: Boolean,
required: false,
default: null
},
fields: { fields: {
type: Array, type: Array,
required: false, required: false,
@ -947,7 +1008,8 @@ export default {
isSubmitting: false, isSubmitting: false,
isSubmittingComplete: false, isSubmittingComplete: false,
submittedValues: {}, submittedValues: {},
recalculateButtonDisabledKey: '' recalculateButtonDisabledKey: '',
isAccessibilityMode: false
} }
}, },
computed: { computed: {

@ -3,7 +3,6 @@ const en = {
form_progress: 'Form progress', form_progress: 'Form progress',
close: 'Close', close: 'Close',
uploaded_files: 'Uploaded files', uploaded_files: 'Uploaded files',
minimize: 'Minimize',
signature_drawing_area: 'Signature drawing area. Use mouse or touch to draw your signature.', signature_drawing_area: 'Signature drawing area. Use mouse or touch to draw your signature.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Please upload an image file', please_upload_an_image_file: 'Please upload an image file',
@ -104,7 +103,8 @@ const en = {
files: 'Files', files: 'Files',
signature_is_too_small_or_simple_please_redraw: 'Signature is too small or simple. Please redraw.', signature_is_too_small_or_simple_please_redraw: 'Signature is too small or simple. Please redraw.',
browser_privacy_settings_block_canvas: 'Your browser privacy settings restrict use of the drawing canvas. Please use a different browser or device, or disable privacy settings that block canvas in order to sign.', browser_privacy_settings_block_canvas: 'Your browser privacy settings restrict use of the drawing canvas. Please use a different browser or device, or disable privacy settings that block canvas in order to sign.',
wait_countdown_seconds: 'Wait {countdown} seconds' wait_countdown_seconds: 'Wait {countdown} seconds',
enter_screen_reader_mode: 'Enter screen reader mode'
} }
const es = { const es = {
@ -212,7 +212,8 @@ const es = {
files: 'Archivos', files: 'Archivos',
signature_is_too_small_or_simple_please_redraw: 'La firma es demasiado pequeña o simple. Por favor, vuelve a dibujarla.', signature_is_too_small_or_simple_please_redraw: 'La firma es demasiado pequeña o simple. Por favor, vuelve a dibujarla.',
browser_privacy_settings_block_canvas: 'La configuración de privacidad de su navegador restringe el uso del lienzo de dibujo. Utilice un navegador o dispositivo diferente, o desactive la configuración de privacidad que bloquea el lienzo para firmar.', browser_privacy_settings_block_canvas: 'La configuración de privacidad de su navegador restringe el uso del lienzo de dibujo. Utilice un navegador o dispositivo diferente, o desactive la configuración de privacidad que bloquea el lienzo para firmar.',
wait_countdown_seconds: 'Espera {countdown} segundos' wait_countdown_seconds: 'Espera {countdown} segundos',
enter_screen_reader_mode: 'Activar modo lector de pantalla'
} }
const it = { const it = {
@ -320,7 +321,8 @@ const it = {
files: 'File', files: 'File',
signature_is_too_small_or_simple_please_redraw: 'La firma è troppo piccola o semplice. Ridisegnala, per favore.', signature_is_too_small_or_simple_please_redraw: 'La firma è troppo piccola o semplice. Ridisegnala, per favore.',
browser_privacy_settings_block_canvas: 'Le impostazioni sulla privacy del browser limitano l\'uso dell\'area di disegno. Utilizza un browser o dispositivo diverso oppure disattiva le impostazioni sulla privacy che bloccano il canvas per firmare.', browser_privacy_settings_block_canvas: 'Le impostazioni sulla privacy del browser limitano l\'uso dell\'area di disegno. Utilizza un browser o dispositivo diverso oppure disattiva le impostazioni sulla privacy che bloccano il canvas per firmare.',
wait_countdown_seconds: 'Attendi {countdown} secondi' wait_countdown_seconds: 'Attendi {countdown} secondi',
enter_screen_reader_mode: 'Attiva modalità lettore di schermo'
} }
const de = { const de = {
@ -428,7 +430,8 @@ const de = {
files: 'Dateien', files: 'Dateien',
signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte neu zeichnen.', signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte neu zeichnen.',
browser_privacy_settings_block_canvas: 'Die Datenschutzeinstellungen Ihres Browsers schränken die Nutzung der Zeichenfläche ein. Bitte verwenden Sie einen anderen Browser oder ein anderes Gerät oder deaktivieren Sie die Datenschutzeinstellungen, die Canvas blockieren, um zu unterschreiben.', browser_privacy_settings_block_canvas: 'Die Datenschutzeinstellungen Ihres Browsers schränken die Nutzung der Zeichenfläche ein. Bitte verwenden Sie einen anderen Browser oder ein anderes Gerät oder deaktivieren Sie die Datenschutzeinstellungen, die Canvas blockieren, um zu unterschreiben.',
wait_countdown_seconds: 'Bitte {countdown} Sekunden warten' wait_countdown_seconds: 'Bitte {countdown} Sekunden warten',
enter_screen_reader_mode: 'Screenreader-Modus aktivieren'
} }
const fr = { const fr = {
@ -536,7 +539,8 @@ const fr = {
files: 'Fichiers', files: 'Fichiers',
signature_is_too_small_or_simple_please_redraw: 'La signature est trop petite ou trop simple. Veuillez la redessiner.', signature_is_too_small_or_simple_please_redraw: 'La signature est trop petite ou trop simple. Veuillez la redessiner.',
browser_privacy_settings_block_canvas: 'Les paramètres de confidentialité de votre navigateur empêchent l\'utilisation du canevas de dessin. Veuillez utiliser un autre navigateur ou appareil, ou désactiver les paramètres de confidentialité qui bloquent le canevas pour signer.', browser_privacy_settings_block_canvas: 'Les paramètres de confidentialité de votre navigateur empêchent l\'utilisation du canevas de dessin. Veuillez utiliser un autre navigateur ou appareil, ou désactiver les paramètres de confidentialité qui bloquent le canevas pour signer.',
wait_countdown_seconds: 'Veuillez patienter {countdown} secondes' wait_countdown_seconds: 'Veuillez patienter {countdown} secondes',
enter_screen_reader_mode: 'Activer le mode lecteur d\'écran'
} }
const pl = { const pl = {
@ -644,7 +648,8 @@ const pl = {
files: 'Pliki', files: 'Pliki',
signature_is_too_small_or_simple_please_redraw: 'Podpis jest zbyt mały lub zbyt prosty. Proszę narysować go ponownie.', signature_is_too_small_or_simple_please_redraw: 'Podpis jest zbyt mały lub zbyt prosty. Proszę narysować go ponownie.',
browser_privacy_settings_block_canvas: 'Ustawienia prywatności przeglądarki blokują użycie obszaru rysowania. Użyj innej przeglądarki lub urządzenia albo wyłącz ustawienia prywatności blokujące canvas, aby podpisać.', browser_privacy_settings_block_canvas: 'Ustawienia prywatności przeglądarki blokują użycie obszaru rysowania. Użyj innej przeglądarki lub urządzenia albo wyłącz ustawienia prywatności blokujące canvas, aby podpisać.',
wait_countdown_seconds: 'Poczekaj {countdown} sekund' wait_countdown_seconds: 'Poczekaj {countdown} sekund',
enter_screen_reader_mode: 'Włącz tryb czytnika ekranu'
} }
const uk = { const uk = {
@ -752,7 +757,8 @@ const uk = {
files: 'Файли', files: 'Файли',
signature_is_too_small_or_simple_please_redraw: 'Підпис занадто маленький або надто простий. Будь ласка, перемалюйте.', signature_is_too_small_or_simple_please_redraw: 'Підпис занадто маленький або надто простий. Будь ласка, перемалюйте.',
browser_privacy_settings_block_canvas: 'Налаштування конфіденційності вашого браузера блокують використання полотна для малювання. Будь ласка, скористайтеся іншим браузером або пристроєм, або вимкніть налаштування конфіденційності, що блокують canvas, щоб підписати.', browser_privacy_settings_block_canvas: 'Налаштування конфіденційності вашого браузера блокують використання полотна для малювання. Будь ласка, скористайтеся іншим браузером або пристроєм, або вимкніть налаштування конфіденційності, що блокують canvas, щоб підписати.',
wait_countdown_seconds: 'Зачекайте {countdown} секунд' wait_countdown_seconds: 'Зачекайте {countdown} секунд',
enter_screen_reader_mode: 'Увімкнути режим читання з екрану'
} }
const cs = { const cs = {
@ -860,7 +866,8 @@ const cs = {
files: 'Soubory', files: 'Soubory',
signature_is_too_small_or_simple_please_redraw: 'Podpis je příliš malý nebo jednoduchý. Nakreslete jej prosím znovu.', signature_is_too_small_or_simple_please_redraw: 'Podpis je příliš malý nebo jednoduchý. Nakreslete jej prosím znovu.',
browser_privacy_settings_block_canvas: 'Nastavení soukromí vašeho prohlížeče omezuje použití kreslicího plátna. Použijte prosím jiný prohlížeč nebo zařízení, nebo vypněte nastavení soukromí blokující canvas pro podepsání.', browser_privacy_settings_block_canvas: 'Nastavení soukromí vašeho prohlížeče omezuje použití kreslicího plátna. Použijte prosím jiný prohlížeč nebo zařízení, nebo vypněte nastavení soukromí blokující canvas pro podepsání.',
wait_countdown_seconds: 'Počkejte {countdown} sekund' wait_countdown_seconds: 'Počkejte {countdown} sekund',
enter_screen_reader_mode: 'Zapnout režim čtečky obrazovky'
} }
const pt = { const pt = {
@ -968,7 +975,8 @@ const pt = {
files: 'Arquivos', files: 'Arquivos',
signature_is_too_small_or_simple_please_redraw: 'A assinatura é muito pequena ou simples. Por favor, redesenhe.', signature_is_too_small_or_simple_please_redraw: 'A assinatura é muito pequena ou simples. Por favor, redesenhe.',
browser_privacy_settings_block_canvas: 'As configurações de privacidade do seu navegador restringem o uso da área de desenho. Use um navegador ou dispositivo diferente, ou desative as configurações de privacidade que bloqueiam o canvas para assinar.', browser_privacy_settings_block_canvas: 'As configurações de privacidade do seu navegador restringem o uso da área de desenho. Use um navegador ou dispositivo diferente, ou desative as configurações de privacidade que bloqueiam o canvas para assinar.',
wait_countdown_seconds: 'Aguarde {countdown} segundos' wait_countdown_seconds: 'Aguarde {countdown} segundos',
enter_screen_reader_mode: 'Ativar modo leitor de tela'
} }
const he = { const he = {
@ -1076,7 +1084,8 @@ const he = {
files: 'קבצים', files: 'קבצים',
signature_is_too_small_or_simple_please_redraw: 'החתימה קטנה או פשוטה מדי. אנא חתום מחדש.', signature_is_too_small_or_simple_please_redraw: 'החתימה קטנה או פשוטה מדי. אנא חתום מחדש.',
browser_privacy_settings_block_canvas: 'הגדרות הפרטיות של הדפדפן שלך מגבילות את השימוש באזור הציור. אנא השתמש בדפדפן או מכשיר אחר, או בטל את הגדרות הפרטיות החוסמות canvas כדי לחתום.', browser_privacy_settings_block_canvas: 'הגדרות הפרטיות של הדפדפן שלך מגבילות את השימוש באזור הציור. אנא השתמש בדפדפן או מכשיר אחר, או בטל את הגדרות הפרטיות החוסמות canvas כדי לחתום.',
wait_countdown_seconds: 'המתן {countdown} שניות' wait_countdown_seconds: 'המתן {countdown} שניות',
enter_screen_reader_mode: 'הפעל מצב קורא מסך'
} }
const nl = { const nl = {
@ -1184,7 +1193,8 @@ const nl = {
files: 'Bestanden', files: 'Bestanden',
signature_is_too_small_or_simple_please_redraw: 'De handtekening is te klein of te eenvoudig. Teken opnieuw.', signature_is_too_small_or_simple_please_redraw: 'De handtekening is te klein of te eenvoudig. Teken opnieuw.',
browser_privacy_settings_block_canvas: 'De privacyinstellingen van uw browser beperken het gebruik van het tekenveld. Gebruik een andere browser of ander apparaat, of schakel de privacyinstellingen uit die canvas blokkeren om te ondertekenen.', browser_privacy_settings_block_canvas: 'De privacyinstellingen van uw browser beperken het gebruik van het tekenveld. Gebruik een andere browser of ander apparaat, of schakel de privacyinstellingen uit die canvas blokkeren om te ondertekenen.',
wait_countdown_seconds: 'Wacht {countdown} seconden' wait_countdown_seconds: 'Wacht {countdown} seconden',
enter_screen_reader_mode: 'Schermlezer-modus inschakelen'
} }
const ar = { const ar = {
@ -1292,7 +1302,8 @@ const ar = {
files: 'الملفات', files: 'الملفات',
signature_is_too_small_or_simple_please_redraw: 'التوقيع صغير جدًا أو بسيط جدًا. يرجى إعادة رسمه.', signature_is_too_small_or_simple_please_redraw: 'التوقيع صغير جدًا أو بسيط جدًا. يرجى إعادة رسمه.',
browser_privacy_settings_block_canvas: 'إعدادات الخصوصية في متصفحك تمنع استخدام لوحة الرسم. يرجى استخدام متصفح أو جهاز مختلف، أو تعطيل إعدادات الخصوصية التي تحظر canvas للتوقيع.', browser_privacy_settings_block_canvas: 'إعدادات الخصوصية في متصفحك تمنع استخدام لوحة الرسم. يرجى استخدام متصفح أو جهاز مختلف، أو تعطيل إعدادات الخصوصية التي تحظر canvas للتوقيع.',
wait_countdown_seconds: 'انتظر {countdown} ثانية' wait_countdown_seconds: 'انتظر {countdown} ثانية',
enter_screen_reader_mode: 'تفعيل وضع قارئ الشاشة'
} }
const ko = { const ko = {
@ -1400,7 +1411,8 @@ const ko = {
files: '파일', files: '파일',
signature_is_too_small_or_simple_please_redraw: '서명이 너무 작거나 단순합니다. 다시 그려주세요.', signature_is_too_small_or_simple_please_redraw: '서명이 너무 작거나 단순합니다. 다시 그려주세요.',
browser_privacy_settings_block_canvas: '브라우저 개인정보 보호 설정으로 인해 그리기 캔버스를 사용할 수 없습니다. 다른 브라우저나 기기를 사용하거나, 서명을 위해 캔버스를 차단하는 개인정보 보호 설정을 비활성화해 주세요.', browser_privacy_settings_block_canvas: '브라우저 개인정보 보호 설정으로 인해 그리기 캔버스를 사용할 수 없습니다. 다른 브라우저나 기기를 사용하거나, 서명을 위해 캔버스를 차단하는 개인정보 보호 설정을 비활성화해 주세요.',
wait_countdown_seconds: '{countdown}초 기다리세요' wait_countdown_seconds: '{countdown}초 기다리세요',
enter_screen_reader_mode: '스크린 리더 모드 활성화'
} }
const ja = { const ja = {
@ -1508,7 +1520,8 @@ const ja = {
files: 'ファイル', files: 'ファイル',
signature_is_too_small_or_simple_please_redraw: '署名が小さすぎるか単純すぎます。もう一度描いてください。', signature_is_too_small_or_simple_please_redraw: '署名が小さすぎるか単純すぎます。もう一度描いてください。',
browser_privacy_settings_block_canvas: 'ブラウザのプライバシー設定により、描画キャンバスの使用が制限されています。別のブラウザまたはデバイスを使用するか、署名するためにキャンバスをブロックするプライバシー設定を無効にしてください。', browser_privacy_settings_block_canvas: 'ブラウザのプライバシー設定により、描画キャンバスの使用が制限されています。別のブラウザまたはデバイスを使用するか、署名するためにキャンバスをブロックするプライバシー設定を無効にしてください。',
wait_countdown_seconds: '{countdown} 秒お待ちください' wait_countdown_seconds: '{countdown} 秒お待ちください',
enter_screen_reader_mode: 'スクリーンリーダーモードを有効にする'
} }
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar, ko, ja } const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar, ko, ja }

@ -18,7 +18,10 @@
class="btn btn-outline btn-sm reupload-button" class="btn btn-outline btn-sm reupload-button"
@click.prevent="remove" @click.prevent="remove"
> >
<IconReload :width="16" aria-hidden="true" /> <IconReload
:width="16"
aria-hidden="true"
/>
{{ t('reupload') }} {{ t('reupload') }}
</button> </button>
</div> </div>

@ -29,7 +29,10 @@
class="btn btn-outline font-medium btn-sm type-text-button" class="btn btn-outline font-medium btn-sm type-text-button"
@click="toggleTextInput" @click="toggleTextInput"
> >
<IconTextSize :width="16" aria-hidden="true" /> <IconTextSize
:width="16"
aria-hidden="true"
/>
<span class="hidden sm:inline"> <span class="hidden sm:inline">
{{ t('type') }} {{ t('type') }}
</span> </span>
@ -47,7 +50,10 @@
class="btn btn-outline font-medium btn-sm type-text-button" class="btn btn-outline font-medium btn-sm type-text-button"
@click="toggleTextInput" @click="toggleTextInput"
> >
<IconSignature :width="16" aria-hidden="true" /> <IconSignature
:width="16"
aria-hidden="true"
/>
<span class="hidden sm:inline"> <span class="hidden sm:inline">
{{ t('draw') }} {{ t('draw') }}
</span> </span>
@ -57,10 +63,19 @@
class="md:tooltip" class="md:tooltip"
:data-tip="t('click_to_upload')" :data-tip="t('click_to_upload')"
> >
<label role="button" tabindex="0" :aria-label="t('click_to_upload')" class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button" @keydown.enter.prevent="$el.querySelector('input')?.click()" @keydown.space.prevent="$el.querySelector('input')?.click()"> <button
<IconUpload :width="16" aria-hidden="true" /> type="button"
:aria-label="t('click_to_upload')"
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button"
@click="$refs.uploadInput.click()"
>
<IconUpload
:width="16"
aria-hidden="true"
/>
<input <input
:key="uploadImageInputKey" :key="uploadImageInputKey"
ref="uploadInput"
type="file" type="file"
hidden hidden
accept="image/*" accept="image/*"
@ -69,7 +84,7 @@
<span class="hidden sm:inline"> <span class="hidden sm:inline">
{{ t('upload') }} {{ t('upload') }}
</span> </span>
</label> </button>
</span> </span>
<button <button
v-if="modelValue || computedPreviousValue" v-if="modelValue || computedPreviousValue"
@ -77,7 +92,10 @@
class="btn font-medium btn-outline btn-sm clear-canvas-button" class="btn font-medium btn-outline btn-sm clear-canvas-button"
@click="remove" @click="remove"
> >
<IconReload :width="16" aria-hidden="true" /> <IconReload
:width="16"
aria-hidden="true"
/>
{{ t('clear') }} {{ t('clear') }}
</button> </button>
<button <button
@ -86,7 +104,10 @@
class="btn font-medium btn-outline btn-sm clear-canvas-button" class="btn font-medium btn-outline btn-sm clear-canvas-button"
@click="clear" @click="clear"
> >
<IconReload :width="16" aria-hidden="true" /> <IconReload
:width="16"
aria-hidden="true"
/>
{{ t('clear') }} {{ t('clear') }}
</button> </button>
<button <button

@ -69,13 +69,11 @@
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }" :class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
:data-tip="t('take_photo')" :data-tip="t('take_photo')"
> >
<label <button
role="button" type="button"
tabindex="0"
:aria-label="t('take_photo')" :aria-label="t('take_photo')"
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button" class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button"
@keydown.enter.prevent="$el.querySelector('input')?.click()" @click="$refs.takePhotoInput.click()"
@keydown.space.prevent="$el.querySelector('input')?.click()"
> >
<IconCamera <IconCamera
:width="16" :width="16"
@ -83,6 +81,7 @@
/> />
<input <input
:key="uploadImageInputKey" :key="uploadImageInputKey"
ref="takePhotoInput"
type="file" type="file"
hidden hidden
accept="image/*" accept="image/*"
@ -91,7 +90,7 @@
<span class="hidden sm:inline"> <span class="hidden sm:inline">
{{ t('upload') }} {{ t('upload') }}
</span> </span>
</label> </button>
</span> </span>
<button <button
v-if="modelValue || computedPreviousValue" v-if="modelValue || computedPreviousValue"

@ -0,0 +1,27 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: document_metadata
#
# id :bigint not null, primary key
# blob_checksum :string not null
# text_runs :text not null
# created_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_document_metadata_on_account_id_and_blob_checksum (account_id,blob_checksum) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
#
class DocumentMetadata < ApplicationRecord
belongs_to :account
attribute :text_runs, :string, default: -> { {} }
serialize :text_runs, coder: JSON
end

@ -1,6 +1,6 @@
<% uuid = local_assigns[:uuid] || SecureRandom.uuid %> <% uuid = local_assigns[:uuid] || SecureRandom.uuid %>
<% title_id = "#{uuid}-title" %> <% title_id = "#{uuid}-title" %>
<%= tag.dialog id: uuid, class: "modal items-start overflow-y-auto", inert: true, "aria-labelledby": (title_id if local_assigns[:title]) do %> <%= tag.dialog id: uuid, class: 'modal items-start overflow-y-auto', inert: true, 'aria-labelledby': (title_id if local_assigns[:title]) do %>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none"> <div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none">
<% if local_assigns[:title] %> <% if local_assigns[:title] %>
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium"> <div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">

@ -5,7 +5,7 @@
<% font = field.dig('preferences', 'font') %> <% font = field.dig('preferences', 'font') %>
<% font_type = field.dig('preferences', 'font_type') %> <% font_type = field.dig('preferences', 'font_type') %>
<% font_size_px = (field.dig('preferences', 'font_size').presence || Submissions::GenerateResultAttachments::FONT_SIZE).to_i * local_assigns.fetch(:font_scale) { 1000.0 / PdfUtils::US_LETTER_W } %> <% font_size_px = (field.dig('preferences', 'font_size').presence || Submissions::GenerateResultAttachments::FONT_SIZE).to_i * local_assigns.fetch(:font_scale) { 1000.0 / PdfUtils::US_LETTER_W } %>
<field-value dir="auto" class="flex absolute <%= 'font-courier' if font == 'Courier' %> <%= 'font-times' if font == 'Times' %> <%= 'font-bold' if font_type == 'bold' || font_type == 'bold_italic' %> <%= 'italic' if font_type == 'italic' || font_type == 'bold_italic' %> <%= align == 'right' ? 'text-right' : (align == 'center' ? 'text-center' : '') %>" style="<%= "color: #{color}; " if color.present? %><%= "background: #{bg_color}; " if bg_color.present? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; font-size: <%= fs = "clamp(1pt, #{font_size_px / 10}vw, #{font_size_px}px)" %>; line-height: calc(<%= fs %> * 1.3); font-size: <%= fs = "#{font_size_px / 10}cqmin" %>; line-height: calc(<%= fs %> * 1.3)"> <field-value dir="auto" aria-hidden="true" class="flex absolute <%= 'font-courier' if font == 'Courier' %> <%= 'font-times' if font == 'Times' %> <%= 'font-bold' if font_type == 'bold' || font_type == 'bold_italic' %> <%= 'italic' if font_type == 'italic' || font_type == 'bold_italic' %> <%= align == 'right' ? 'text-right' : (align == 'center' ? 'text-center' : '') %>" style="<%= "color: #{color}; " if color.present? %><%= "background: #{bg_color}; " if bg_color.present? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; font-size: <%= fs = "clamp(1pt, #{font_size_px / 10}vw, #{font_size_px}px)" %>; line-height: calc(<%= fs %> * 1.3); font-size: <%= fs = "#{font_size_px / 10}cqmin" %>; line-height: calc(<%= fs %> * 1.3)">
<% if field['type'] == 'signature' %> <% if field['type'] == 'signature' %>
<% is_narrow = area['h'].positive? && ((area['w'] * local_assigns[:page_width]).to_f / (area['h'] * local_assigns[:page_height])) > 4.5 %> <% is_narrow = area['h'].positive? && ((area['w'] * local_assigns[:page_width]).to_f / (area['h'] * local_assigns[:page_height])) > 4.5 %>
<div class="flex justify-between w-full h-full gap-1 <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'flex-row' : 'flex-col' %>"> <div class="flex justify-between w-full h-full gap-1 <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'flex-row' : 'flex-col' %>">
@ -48,6 +48,7 @@
</div> </div>
<% elsif field['type'] == 'checkbox' %> <% elsif field['type'] == 'checkbox' %>
<div class="w-full flex items-center justify-center"> <div class="w-full flex items-center justify-center">
<span class="sr-only">☑</span>
<%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %> <%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %>
</div> </div>
<% elsif field['type'].in?(%w[multiple radio]) && area['option_uuid'] %> <% elsif field['type'].in?(%w[multiple radio]) && area['option_uuid'] %>
@ -55,6 +56,7 @@
<% option_name = option['value'].presence || "Option #{field['options'].index(option) + 1}" %> <% option_name = option['value'].presence || "Option #{field['options'].index(option) + 1}" %>
<% if Array.wrap(value).include?(option_name) %> <% if Array.wrap(value).include?(option_name) %>
<div class="w-full flex items-center justify-center"> <div class="w-full flex items-center justify-center">
<span class="sr-only">☑</span>
<%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %> <%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %>
</div> </div>
<% end %> <% end %>

@ -46,7 +46,7 @@
<% end %> <% end %>
</span> </span>
<span> <span>
<button type="submit" form="resend_code_form" class="link"><%= t(:re_send_email) %></button> <button type="submit" form="resend_code_form" id="resend_label" class="link"><%= t(:re_send_email) %></button>
</span> </span>
</div> </div>
</div> </div>

@ -12,6 +12,7 @@
<% delegate_modal_id = nil %> <% delegate_modal_id = nil %>
<div style="max-height: -webkit-fill-available;"> <div style="max-height: -webkit-fill-available;">
<main id="scrollbox"> <main id="scrollbox">
<div id="sr_only_content"></div>
<div class="mx-auto block pb-72" style="max-width: 1000px"> <div class="mx-auto block pb-72" style="max-width: 1000px">
<%# flex block w-full sticky top-0 z-50 space-x-2 items-center bg-yellow-100 p-2 border-y border-yellow-200 transition-transform duration-300 %> <%# flex block w-full sticky top-0 z-50 space-x-2 items-center bg-yellow-100 p-2 border-y border-yellow-200 transition-transform duration-300 %>
<%= local_assigns[:banner_html] || capture do %> <%= local_assigns[:banner_html] || capture do %>
@ -63,10 +64,10 @@
<% end %> <% end %>
</div> </div>
</header> </header>
<scroll-buttons class="fixed right-5 top-2 hidden md:flex gap-1 z-50 ease-in-out opacity-0 -translate-y-10"> <scroll-buttons inert class="fixed right-5 top-2 hidden md:flex gap-1 z-50 ease-in-out opacity-0 -translate-y-10">
<% if @form_configs[:with_delegate] %> <% if @form_configs[:with_delegate] %>
<modal-button data-target="<%= delegate_modal_id %>"> <modal-button data-target="<%= delegate_modal_id %>">
<button id="delegate_button_mobile" type="button" class="btn btn-sm px-0" aria-label="<%= t(:delegate) %>" tabindex="-1"> <button id="delegate_button_mobile" type="button" class="btn btn-sm px-0" aria-label="<%= t(:delegate) %>">
<span class="min-[1366px]:inline hidden px-3"> <span class="min-[1366px]:inline hidden px-3">
<%= t(:delegate) %> <%= t(:delegate) %>
</span> </span>
@ -77,12 +78,12 @@
</modal-button> </modal-button>
<% if @form_configs[:with_decline] %> <% if @form_configs[:with_decline] %>
<modal-button data-target="<%= decline_modal_id %>"> <modal-button data-target="<%= decline_modal_id %>">
<button id="decline_button_mobile" type="button" class="btn btn-sm px-2" aria-label="<%= t(:decline) %>" tabindex="-1"> <button id="decline_button_mobile" type="button" class="btn btn-sm px-2" aria-label="<%= t(:decline) %>">
<%= svg_icon('x', class: 'w-5 h-5') %> <%= svg_icon('x', class: 'w-5 h-5') %>
</button> </button>
</modal-button> </modal-button>
<% end %> <% end %>
<download-button role="button" tabindex="-1" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2" aria-label="<%= t('download') %>"> <download-button role="button" tabindex="0" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2" aria-label="<%= t('download') %>">
<span data-target="download-button.defaultButton"> <span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %> <%= svg_icon('download', class: 'w-5 h-5') %>
</span> </span>
@ -93,7 +94,7 @@
<% else %> <% else %>
<% if @form_configs[:with_decline] %> <% if @form_configs[:with_decline] %>
<modal-button data-target="<%= decline_modal_id %>"> <modal-button data-target="<%= decline_modal_id %>">
<button id="decline_button_mobile" type="button" class="btn btn-sm px-0" aria-label="<%= t(:decline) %>" tabindex="-1"> <button id="decline_button_mobile" type="button" class="btn btn-sm px-0" aria-label="<%= t(:decline) %>">
<span class="min-[1366px]:inline hidden px-3"> <span class="min-[1366px]:inline hidden px-3">
<%= t(:decline) %> <%= t(:decline) %>
</span> </span>
@ -103,7 +104,7 @@
</button> </button>
</modal-button> </modal-button>
<% end %> <% end %>
<download-button role="button" tabindex="-1" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2" aria-label="<%= t('download') %>"> <download-button role="button" tabindex="0" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2" aria-label="<%= t('download') %>">
<span data-target="download-button.defaultButton"> <span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %> <%= svg_icon('download', class: 'w-5 h-5') %>
</span> </span>
@ -117,6 +118,7 @@
<% schema.each do |item| %> <% schema.each do |item| %>
<% document = @submitter.submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %> <% document = @submitter.submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %>
<div id="document-<%= document.uuid %>"> <div id="document-<%= document.uuid %>">
<div class="sr_only_content"></div>
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %> <% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %> <% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.last&.metadata || Templates::ProcessDocument::US_LETTER_SIZE %> <% lazyload_metadata = document.preview_images.last&.metadata || Templates::ProcessDocument::US_LETTER_SIZE %>

@ -150,6 +150,7 @@ Rails.application.routes.draw do
resources :decline, only: %i[create], controller: 'submit_form_decline' resources :decline, only: %i[create], controller: 'submit_form_decline'
resources :delegate, only: %i[create], controller: 'submit_form_delegate' resources :delegate, only: %i[create], controller: 'submit_form_delegate'
resources :invite, only: %i[create], controller: 'submit_form_invite' resources :invite, only: %i[create], controller: 'submit_form_invite'
resources :metadata, only: %i[index], controller: 'submit_form_metadata'
resources :debug, only: %i[index], controller: 'submissions_debug' if Rails.env.development? resources :debug, only: %i[index], controller: 'submissions_debug' if Rails.env.development?
get :completed get :completed
get :delegated get :delegated

@ -0,0 +1,15 @@
# frozen_string_literal: true
class CreateDocumentMetadata < ActiveRecord::Migration[8.1]
def change
create_table :document_metadata do |t|
t.references :account, null: false, foreign_key: true, index: false
t.string :blob_checksum, null: false
t.text :text_runs, null: false
t.datetime :created_at, null: false
end
add_index :document_metadata, %i[account_id blob_checksum], unique: true
end
end

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_03_27_100000) do ActiveRecord::Schema[8.1].define(version: 2026_04_16_100000) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "btree_gin" enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
@ -168,6 +168,14 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_27_100000) do
t.index ["submitter_id"], name: "index_document_generation_events_on_submitter_id" t.index ["submitter_id"], name: "index_document_generation_events_on_submitter_id"
end end
create_table "document_metadata", force: :cascade do |t|
t.bigint "account_id", null: false
t.string "blob_checksum", null: false
t.datetime "created_at", null: false
t.text "text_runs", null: false
t.index ["account_id", "blob_checksum"], name: "index_document_metadata_on_account_id_and_blob_checksum", unique: true
end
create_table "dynamic_document_versions", force: :cascade do |t| create_table "dynamic_document_versions", force: :cascade do |t|
t.text "areas", null: false t.text "areas", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@ -553,6 +561,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_27_100000) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "document_generation_events", "submitters" add_foreign_key "document_generation_events", "submitters"
add_foreign_key "document_metadata", "accounts"
add_foreign_key "dynamic_document_versions", "dynamic_documents" add_foreign_key "dynamic_document_versions", "dynamic_documents"
add_foreign_key "dynamic_documents", "templates" add_foreign_key "dynamic_documents", "templates"
add_foreign_key "email_events", "accounts" add_foreign_key "email_events", "accounts"

@ -0,0 +1,34 @@
# frozen_string_literal: true
module DocumentMetadatas
module_function
def find_or_create_for_document(document, account_id:)
checksum = document.blob.checksum
metadata = DocumentMetadata.find_by(account_id:, blob_checksum: checksum)
metadata ||= DocumentMetadata.create!(account_id:, blob_checksum: checksum, text_runs: build_text_runs(document))
metadata
rescue ActiveRecord::RecordNotUnique
retry
end
def build_text_runs(document)
number_of_pages = document.metadata.dig('pdf', 'number_of_pages').to_i
return {} if number_of_pages.zero?
Pdfium::Document.open_bytes(document.download) do |doc|
(0...doc.page_count).each_with_object({}) do |page_index, acc|
page = doc.get_page(page_index)
acc[page_index] = page.text_objects.map do |node|
{ text: node.content, x: node.x, y: node.y, w: node.w, h: node.h, font_size: node.font_size }
end
ensure
page&.close
end
end
end
end

@ -39,6 +39,16 @@ class Pdfium
FPDF_RENDER_FORCEHALFTONE = 0x400 FPDF_RENDER_FORCEHALFTONE = 0x400
FPDF_PRINTING = 0x800 FPDF_PRINTING = 0x800
TextObject = Struct.new(:content, :x, :y, :w, :h, :font_size) do
def endx
@endx ||= x + w
end
def endy
@endy ||= y + h
end
end
TextNode = Struct.new(:content, :x, :y, :w, :h) do TextNode = Struct.new(:content, :x, :y, :w, :h) do
def endx def endx
@endx ||= x + w @endx ||= x + w
@ -117,6 +127,10 @@ class Pdfium
attach_function :FPDFPathSegment_GetType, [:FPDF_PATHSEGMENT], :int attach_function :FPDFPathSegment_GetType, [:FPDF_PATHSEGMENT], :int
attach_function :FPDFPathSegment_GetPoint, %i[FPDF_PATHSEGMENT pointer pointer], :int attach_function :FPDFPathSegment_GetPoint, %i[FPDF_PATHSEGMENT pointer pointer], :int
# Text page object functions (per-run Tj/TJ extraction)
attach_function :FPDFTextObj_GetText, %i[FPDF_PAGEOBJECT FPDF_TEXTPAGE pointer ulong], :ulong
attach_function :FPDFTextObj_GetFontSize, %i[FPDF_PAGEOBJECT pointer], :int
# Page object types # Page object types
FPDF_PAGEOBJ_UNKNOWN = 0 FPDF_PAGEOBJ_UNKNOWN = 0
FPDF_PAGEOBJ_TEXT = 1 FPDF_PAGEOBJ_TEXT = 1
@ -515,6 +529,90 @@ class Pdfium
Pdfium.FPDFText_ClosePage(text_page) if text_page && !text_page.null? Pdfium.FPDFText_ClosePage(text_page) if text_page && !text_page.null?
end end
def text_objects
return @text_objects if @text_objects
ensure_not_closed!
@text_objects = []
object_count = Pdfium.FPDFPage_CountObjects(page_ptr)
return @text_objects if object_count.zero?
text_page = Pdfium.FPDFText_LoadPage(page_ptr)
if text_page.null?
Pdfium.check_last_error("Failed to load text page #{page_index}")
raise PdfiumError, "Failed to load text page #{page_index}, pointer is NULL."
end
left_ptr = FFI::MemoryPointer.new(:float)
bottom_ptr = FFI::MemoryPointer.new(:float)
right_ptr = FFI::MemoryPointer.new(:float)
top_ptr = FFI::MemoryPointer.new(:float)
font_size_ptr = FFI::MemoryPointer.new(:float)
object_count.times do |i|
page_object = Pdfium.FPDFPage_GetObject(page_ptr, i)
next if page_object.null?
next unless Pdfium.FPDFPageObj_GetType(page_object) == Pdfium::FPDF_PAGEOBJ_TEXT
needed_bytes = Pdfium.FPDFTextObj_GetText(page_object, text_page, FFI::Pointer::NULL, 0)
next if needed_bytes < 4
buffer = FFI::MemoryPointer.new(:uint8, needed_bytes)
written = Pdfium.FPDFTextObj_GetText(page_object, text_page, buffer, needed_bytes)
next if written < 4
content = buffer.read_bytes(written - 2).force_encoding('UTF-16LE').encode('UTF-8')
next if content.empty?
next if Pdfium.FPDFPageObj_GetBounds(page_object, left_ptr, bottom_ptr, right_ptr, top_ptr).zero?
obj_left = left_ptr.read_float
obj_bottom = bottom_ptr.read_float
obj_right = right_ptr.read_float
obj_top = top_ptr.read_float
obj_width = obj_right - obj_left
obj_height = obj_top - obj_bottom
next if obj_width <= 0 || obj_height <= 0
font_size =
if Pdfium.FPDFTextObj_GetFontSize(page_object, font_size_ptr) == 0
obj_height
else
font_size_ptr.read_float
end
font_size = 8 if font_size == 1
norm_x = obj_left / width
norm_y = (height - obj_top) / height
norm_w = obj_width / width
norm_h = obj_height / height
@text_objects << TextObject.new(content, norm_x, norm_y, norm_w, norm_h, font_size)
end
y_threshold = 4.0 / width
@text_objects = @text_objects.sort do |a, b|
(a.endy - b.endy).abs < y_threshold ? a.x <=> b.x : a.endy <=> b.endy
end
ensure
Pdfium.FPDFText_ClosePage(text_page) if text_page && !text_page.null?
end
def line_nodes def line_nodes
return @line_nodes if @line_nodes return @line_nodes if @line_nodes

@ -613,7 +613,7 @@ RSpec.describe 'Signing Form' do
visit submit_form_path(slug: submitter.slug) visit submit_form_path(slug: submitter.slug)
find('#expand_form_button').click find('#expand_form_button').click
click_link 'Type' click_button 'Type'
fill_in 'signature_text_input', with: 'John Doe' fill_in 'signature_text_input', with: 'John Doe'
click_button 'Sign and Complete' click_button 'Sign and Complete'
@ -752,7 +752,7 @@ RSpec.describe 'Signing Form' do
visit submit_form_path(slug: submitter.slug) visit submit_form_path(slug: submitter.slug)
find('#expand_form_button').click find('#expand_form_button').click
click_link 'Draw' click_button 'Draw'
draw_canvas draw_canvas
click_button 'Complete' click_button 'Complete'
@ -1169,7 +1169,7 @@ RSpec.describe 'Signing Form' do
find('#decline_button').click find('#decline_button').click
fill_in 'reason', with: 'I do not agree with the terms' fill_in 'reason', with: 'I do not agree with the terms'
click_button 'Decline' within('dialog[open]') { click_button 'Decline' }
expect(page).to have_content('Form has been declined') expect(page).to have_content('Form has been declined')
@ -1193,7 +1193,7 @@ RSpec.describe 'Signing Form' do
find('#delegate_button').click find('#delegate_button').click
fill_in 'email', with: 'delegate@example.com' fill_in 'email', with: 'delegate@example.com'
click_button 'Delegate' within('dialog[open]') { click_button 'Delegate' }
expect(page).to have_content('Document has been delegated') expect(page).to have_content('Document has been delegated')

Loading…
Cancel
Save