Compare commits

...

23 Commits

Author SHA1 Message Date
Alex Turchyn f87ef670d1 Merge from docusealco/wip 2025-11-10 13:08:28 +02:00
Pete Matsyburka daba2505bc fix detection 2025-11-09 21:33:47 +02:00
Pete Matsyburka 71528eda45 map page nodes 2025-11-09 08:51:00 +02:00
Pete Matsyburka 269e2beeaa fix preview 2025-11-07 10:20:41 +02:00
Pete Matsyburka 72dd2c6bc5 add template download 2025-11-05 12:07:11 +02:00
Pete Matsyburka 9758f7a15f adjust detection 2025-11-04 16:17:13 +02:00
Alex Turchyn 2521eb5a4a Merge from docusealco/wip 2025-11-03 20:16:45 +02:00
Pete Matsyburka bf6afc5e61 adjust detection 2025-11-03 19:24:09 +02:00
Pete Matsyburka 291d869843 adjust detect 2025-11-03 09:49:25 +02:00
Pete Matsyburka cdfecb2a8f fix role validation 2025-11-03 09:17:50 +02:00
Pete Matsyburka c0df8e0ec3 close doc 2025-11-02 08:57:58 +02:00
Pete Matsyburka a21f7d1653 fix padding 2025-11-02 08:13:40 +02:00
Pete Matsyburka 79e2514416 fix model name 2025-11-02 08:11:31 +02:00
Pete Matsyburka 80a7385183 fix redirect 2025-11-02 08:09:54 +02:00
Pete Matsyburka 1c8a7b6a7c detect fields 2025-11-02 08:09:54 +02:00
Pete Matsyburka b46ced2b6f add log 2025-10-31 10:26:10 +02:00
Pete Matsyburka 5ae479644e focus on canvas click 2025-10-31 10:18:39 +02:00
Pete Matsyburka cb66eb1293 fix redirect 2025-10-31 10:10:19 +02:00
Pete Matsyburka b4d5a2cad4 dangerous extension error 2025-10-30 08:47:01 +02:00
Pete Matsyburka 32691b0c0d add background 2025-10-29 10:10:04 +02:00
Pete Matsyburka 9163f744a3 remove sign up button 2025-10-27 20:03:33 +02:00
Pete Matsyburka 23ecce51a3 hide archived completed page 2025-10-22 22:03:39 +03:00
Pete Matsyburka 747ffa8cf1 adjust signature ID size 2025-10-15 13:38:53 +03:00
32 changed files with 1417 additions and 52 deletions
+1
View File
@@ -37,3 +37,4 @@ yarn-debug.log*
/docuseal
/ee
dump.rdb
*.onnx
+6 -2
View File
@@ -9,6 +9,7 @@ RUN apk --no-cache add fontforge wget && \
wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && \
wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/OFL.txt && \
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/1.0.0/model_704_int8.onnx" && \
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux
@@ -50,7 +51,7 @@ ENV OPENSSL_CONF=/app/openssl_legacy.cnf
WORKDIR /app
RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev@edge yaml-dev redis libheif@edge vips-heif@edge gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN echo $'.include = /etc/ssl/openssl.cnf\n\
\n\
@@ -66,7 +67,9 @@ activate = 1' >> /app/openssl_legacy.cnf
COPY ./Gemfile ./Gemfile.lock ./
RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf
RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
RUN echo 'https://dl-cdn.alpinelinux.org/alpine/edge/main' >> /etc/apk/repositories && echo 'https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache onnxruntime
COPY ./bin ./bin
COPY ./app ./app
@@ -83,6 +86,7 @@ COPY --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.
COPY --from=download /fonts/FreeSans.ttf /usr/share/fonts/freefont
COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
COPY --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt
COPY --from=download /model.onnx /app/tmp/model.onnx
COPY --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts
+2
View File
@@ -24,7 +24,9 @@ gem 'image_processing'
gem 'jwt'
gem 'lograge'
gem 'mysql2', require: false
gem 'numo-narray'
gem 'oj'
gem 'onnxruntime'
gem 'pagy'
gem 'pg', require: false
gem 'premailer-rails'
+11
View File
@@ -357,9 +357,18 @@ GEM
racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-musl)
racc (~> 1.4)
numo-narray (0.9.2.1)
oj (3.16.11)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
onnxruntime (0.10.1)
ffi
onnxruntime (0.10.1-aarch64-linux)
ffi
onnxruntime (0.10.1-arm64-darwin)
ffi
onnxruntime (0.10.1-x86_64-linux)
ffi
openssl (3.3.0)
orm_adapter (0.5.0)
os (1.1.4)
@@ -638,7 +647,9 @@ DEPENDENCIES
letter_opener_web
lograge
mysql2
numo-narray
oj
onnxruntime
pagy
pg
premailer-rails
+1
View File
@@ -47,6 +47,7 @@ class AccountsController < ApplicationController
authorize!(:manage, current_account)
true_user.update!(locked_at: Time.current, email: true_user.email.sub('@', '+removed@'))
true_user.account.update!(archived_at: Time.current)
# rubocop:disable Layout/LineLength
render turbo_stream: turbo_stream.replace(
@@ -34,6 +34,10 @@ module Api
end
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
rescue Submitters::MaliciousFileExtension => e
Rollbar.error(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_entity
end
def build_new_cookie_signatures_json(submitter, attachment)
@@ -17,8 +17,10 @@ class ConsoleRedirectController < ApplicationController
scope: :console,
exp: 1.minute.from_now.to_i)
path = Addressable::URI.parse(params[:redir]).path if params[:redir].to_s.starts_with?(Docuseal::CONSOLE_URL)
redir_uri = Addressable::URI.parse(params[:redir])
path = redir_uri.path if params[:redir].to_s.starts_with?(Docuseal::CONSOLE_URL)
redirect_to("#{Docuseal::CONSOLE_URL}#{path}?#{{ auth: }.to_query}", allow_other_host: true)
redirect_to "#{Docuseal::CONSOLE_URL}#{path}?#{{ **redir_uri&.query_values, 'auth' => auth }.to_query}",
allow_other_host: true
end
end
@@ -29,7 +29,7 @@ class SendSubmissionEmailController < ApplicationController
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! unless already_sent?(@submitter)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
respond_to do |f|
f.html { render :success }
@@ -39,8 +39,11 @@ class SendSubmissionEmailController < ApplicationController
private
def already_sent?(submitter)
EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
event_type: :send, created_at: SEND_DURATION.ago..Time.current)
def can_send?(submitter)
return false if submitter.account.archived_at?
return false if EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
event_type: :send, created_at: SEND_DURATION.ago..Time.current)
true
end
end
@@ -41,6 +41,9 @@ class SubmissionsPreviewController < ApplicationController
def completed
@submission = Submission.find_by!(slug: params[:submissions_preview_slug])
raise ActionController::RoutingError, I18n.t('not_found') if @submission.account.archived_at?
@template = @submission.template
render :completed, layout: 'form'
+3 -1
View File
@@ -75,7 +75,9 @@ class SubmitFormController < ApplicationController
render json: { error: e.message }, status: :unprocessable_content
end
def completed; end
def completed
raise ActionController::RoutingError, I18n.t('not_found') if @submitter.account.archived_at?
end
def success; end
@@ -3,6 +3,12 @@
class TemplateDocumentsController < ApplicationController
load_and_authorize_resource :template
FILES_TTL = 5.minutes
def index
render json: @template.schema_documents.map { |d| ActiveStorage::Blob.proxy_url(d.blob, expires_at: FILES_TTL.from_now.to_i) }
end
def create
if params[:blobs].blank? && params[:files].blank?
return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_content
@@ -6,12 +6,18 @@ class TemplatesDebugController < ApplicationController
DEBUG_FILE = ''
def show
attachment = @template.documents.first
schema_uuids = @template.schema.index_by { |e| e['attachment_uuid'] }
attachment = @template.documents.find { |a| schema_uuids[a.uuid] }
data = attachment.download
pdf = HexaPDF::Document.new(io: StringIO.new(data))
fields = Templates::FindAcroFields.call(pdf, attachment, data)
unless attachment.image?
pdf = HexaPDF::Document.new(io: StringIO.new(data))
fields = Templates::FindAcroFields.call(pdf, attachment, data)
end
fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank?
attachment.metadata['pdf'] ||= {}
attachment.metadata['pdf']['fields'] = fields
@@ -0,0 +1,27 @@
# frozen_string_literal: true
class TemplatesDetectFieldsController < ApplicationController
include ActionController::Live
load_and_authorize_resource :template
def create
response.headers['Content-Type'] = 'text/event-stream'
sse = SSE.new(response.stream)
documents = @template.schema_documents.preload(:blob)
documents.each do |document|
io = StringIO.new(document.download)
Templates::DetectFields.call(io, attachment: document) do |(attachment_uuid, page, fields)|
sse.write({ attachment_uuid:, page:, fields: })
end
end
sse.write({ completed: true })
ensure
response.stream.close
end
end
+2
View File
@@ -156,6 +156,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withPhone: this.dataset.withPhone === 'true',
withVerification: ['true', 'false'].includes(this.dataset.withVerification) ? this.dataset.withVerification === 'true' : null,
withLogo: this.dataset.withLogo !== 'false',
withFieldsDetection: this.dataset.withFieldsDetection === 'true',
editable: this.dataset.editable !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
withPayment: this.dataset.withPayment === 'true',
@@ -166,6 +167,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withConditions: this.dataset.withConditions === 'true',
withGoogleDrive: this.dataset.withGoogleDrive === 'true',
withReplaceAndCloneUpload: true,
withDownload: true,
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
acceptFileTypes: this.dataset.acceptFileTypes,
showTourStartForm: this.dataset.showTourStartForm === 'true'
+4
View File
@@ -553,6 +553,10 @@ export default {
style.color = this.field.preferences.color
}
if (this.field.preferences?.background) {
style.background = this.field.preferences.background
}
return style
},
isNarrow () {
+11 -3
View File
@@ -163,12 +163,20 @@ export default {
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: formData
}).then(resp => resp.json()).then((data) => {
return data
}).then(async (resp) => {
const data = await resp.json()
if (resp.status === 422) {
alert(data.error)
} else {
return data
}
})
}
})).then((result) => {
this.$emit('upload', result)
if (result && result[0]) {
this.$emit('upload', result)
}
}).finally(() => {
this.isLoading = false
})
@@ -123,6 +123,11 @@
v-if="!isDrawInitials"
class="absolute top-0 right-0 left-0 bottom-0"
/>
<label
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
for="initials_text_input"
class="absolute top-0 right-0 left-0 bottom-0"
/>
<canvas
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
+112 -2
View File
@@ -175,7 +175,10 @@
{{ t('save') }}
</span>
</button>
<div class="dropdown dropdown-end">
<div
class="dropdown dropdown-end"
:class="{ 'dropdown-open': isDownloading }"
>
<label
tabindex="0"
class="base-button !rounded-l-none !pl-1 !pr-2 !border-l-neutral-500"
@@ -209,6 +212,30 @@
<span class="whitespace-nowrap">{{ t('preferences') }}</span>
</a>
</li>
<li v-if="withDownload">
<button
class="flex space-x-2"
:disabled="isDownloading"
@click.stop.prevent="download"
>
<IconInnerShadowTop
v-if="isDownloading"
class="animate-spin w-6 h-6 flex-shrink-0"
/>
<IconDownload
v-else
class="w-6 h-6 flex-shrink-0"
/>
<span
v-if="isDownloading"
class="whitespace-nowrap"
>{{ t('downloading_') }}</span>
<span
v-else
class="whitespace-nowrap"
>{{ t('download') }}</span>
</button>
</li>
</ul>
</div>
</span>
@@ -449,6 +476,7 @@
:default-required-fields="defaultRequiredFields"
:field-types="fieldTypes"
:with-sticky-submitters="withStickySubmitters"
:with-fields-detection="withFieldsDetection"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:only-defined-fields="onlyDefinedFields"
@@ -456,6 +484,7 @@
:show-tour-start-form="showTourStartForm"
@add-field="addField"
@set-draw="[drawField = $event.field, drawOption = $event.option]"
@select-submitter="selectedSubmitter = $event"
@set-draw-type="[drawFieldType = $event, showDrawField = true]"
@set-drag="dragField = $event"
@set-drag-placeholder="$refs.dragPlaceholder.dragPlaceholder = $event"
@@ -510,7 +539,7 @@ import DocumentPreview from './preview'
import DocumentControls from './controls'
import MobileFields from './mobile_fields'
import FieldSubmitter from './field_submitter'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments } from '@tabler/icons-vue'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload } from '@tabler/icons-vue'
import { v4 } from 'uuid'
import { ref, computed, toRaw } from 'vue'
import * as i18n from './i18n'
@@ -536,6 +565,7 @@ export default {
Contenteditable,
IconUsersPlus,
IconChevronDown,
IconDownload,
IconAdjustments,
IconEye,
IconDeviceFloppy
@@ -583,6 +613,11 @@ export default {
required: false,
default: null
},
withDownload: {
type: Boolean,
required: false,
default: false
},
backgroundColor: {
type: String,
required: false,
@@ -618,6 +653,11 @@ export default {
required: false,
default: true
},
withFieldsDetection: {
type: Boolean,
required: false,
default: false
},
withAddPageButton: {
type: Boolean,
required: false,
@@ -799,6 +839,7 @@ export default {
return {
documentRefs: [],
isBreakpointLg: false,
isDownloading: false,
isLoadingBlankPage: false,
isSaving: false,
selectedSubmitter: null,
@@ -957,6 +998,75 @@ export default {
},
methods: {
toRaw,
download () {
this.isDownloading = true
this.baseFetch(`/templates/${this.template.id}/documents`).then(async (response) => {
if (response.ok) {
const urls = await response.json()
const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent)
const isSafariIos = isMobileSafariIos || /iPhone|iPad|iPod/i.test(navigator.userAgent)
if (isSafariIos && urls.length > 1) {
this.downloadSafariIos(urls)
} else {
this.downloadUrls(urls)
}
} else {
alert(this.t('failed_to_download_files'))
}
})
},
downloadUrls (urls) {
const fileRequests = urls.map((url) => {
return () => {
return fetch(url).then(async (resp) => {
const blobUrl = URL.createObjectURL(await resp.blob())
const link = document.createElement('a')
link.href = blobUrl
link.setAttribute('download', decodeURI(url.split('/').pop()))
link.click()
URL.revokeObjectURL(blobUrl)
})
}
})
fileRequests.reduce(
(prevPromise, request) => prevPromise.then(() => request()),
Promise.resolve()
).finally(() => {
this.isDownloading = false
})
},
downloadSafariIos (urls) {
const fileRequests = urls.map((url) => {
return fetch(url).then(async (resp) => {
const blob = await resp.blob()
const blobUrl = URL.createObjectURL(blob.slice(0, blob.size, 'application/octet-stream'))
const link = document.createElement('a')
link.href = blobUrl
link.setAttribute('download', decodeURI(url.split('/').pop()))
return link
})
})
Promise.all(fileRequests).then((links) => {
links.forEach((link, index) => {
setTimeout(() => {
link.click()
URL.revokeObjectURL(link.href)
}, index * 50)
})
}).finally(() => {
this.isDownloading = false
})
},
onDragover (e) {
if (this.$refs.dragPlaceholder?.dragPlaceholder) {
this.$refs.dragPlaceholder.isMask = e.target.id === 'mask'
+128 -3
View File
@@ -208,6 +208,43 @@
</li>
</ul>
</div>
<div
v-if="withFieldsDetection && editable && fields.length < 2"
class="my-2"
>
<button
class="btn w-full"
:class="{ 'bg-base-300': fieldPagesLoaded !== null }"
@click="fieldPagesLoaded !== null ? null : detectFields()"
>
<template v-if="fieldPagesLoaded !== null">
<IconInnerShadowTop
width="22"
class="animate-spin"
/>
<span
v-if="analyzingProgress"
class="hidden md:inline"
>
{{ Math.round(analyzingProgress * 100) }}% {{ t('analyzing_') }}
</span>
<span
v-else
class="hidden md:inline"
>
{{ fieldPagesLoaded }} / {{ numberOfPages }} {{ t('processing_') }}
</span>
</template>
<template v-else>
<IconListSearch width="22" />
<span
class="hidden md:inline"
>
{{ t('autodetect_fields') }}
</span>
</template>
</button>
</div>
<div
v-show="fields.length < 4 && editable && withHelp && showTourStartForm"
class="rounded py-2 px-4 w-full border border-dashed border-base-300"
@@ -231,7 +268,7 @@
import Field from './field'
import FieldType from './field_type'
import FieldSubmitter from './field_submitter'
import { IconLock, IconCirclePlus } from '@tabler/icons-vue'
import { IconLock, IconCirclePlus, IconInnerShadowTop, IconListSearch } from '@tabler/icons-vue'
import IconDrag from './icon_drag'
export default {
@@ -240,11 +277,13 @@ export default {
Field,
FieldType,
IconCirclePlus,
IconListSearch,
IconInnerShadowTop,
FieldSubmitter,
IconDrag,
IconLock
},
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withPayment', 't', 'fieldsDragFieldRef'],
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withPayment', 't', 'fieldsDragFieldRef', 'baseFetch'],
props: {
fields: {
type: Array,
@@ -255,6 +294,11 @@ export default {
required: false,
default: null
},
withFieldsDetection: {
type: Boolean,
required: false,
default: false
},
withSignatureId: {
type: Boolean,
required: false,
@@ -328,15 +372,22 @@ export default {
default: false
}
},
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder'],
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter'],
data () {
return {
fieldPagesLoaded: null,
analyzingProgress: 0,
defaultFieldsSearch: ''
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons,
numberOfPages () {
return this.template.documents.reduce((acc, doc) => {
return acc + doc.metadata?.pdf?.number_of_pages || doc.preview_images.length
}, 0)
},
isShowFieldSearch () {
if (this.withFieldsSearch === false) {
return false
@@ -389,6 +440,80 @@ export default {
this.$emit('set-drag', field)
},
detectFields () {
const fields = []
this.fieldPagesLoaded = 0
this.baseFetch(`/templates/${this.template.id}/detect_fields`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
}).then(async (response) => {
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
while (true) {
const { value, done } = await reader.read()
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n\n')
buffer = lines.pop()
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.replace(/^data: /, '')
const data = JSON.parse(jsonStr)
if (data.error) {
alert(data.error)
this.template.fields = data.fields || fields
break
} else if (data.analyzing) {
this.analyzingProgress = data.progress
} else if (data.completed) {
this.fieldPagesLoaded = null
if (data.submitters) {
this.template.submitters = data.submitters
this.$emit('select-submitter', this.template.submitters[0])
}
this.template.fields = data.fields || fields
this.save()
break
} else if (data.fields) {
data.fields.forEach((f) => {
if (!f.submitter_uuid) {
f.submitter_uuid = this.template.submitters[0].uuid
}
})
this.fieldPagesLoaded += 1
fields.push(...data.fields)
}
}
}
if (done) break
}
}).catch(error => {
console.error('Error in streaming message: ', error)
}).finally(() => {
this.fieldPagesLoaded = null
this.analyzingProgress = null
this.isFieldsLoading = false
})
},
setDragPlaceholder (event) {
this.$emit('set-drag-placeholder', {
offsetX: event.offsetX,
+22
View File
@@ -1,5 +1,9 @@
const en = {
analyzing_: 'Analyzing...',
download: 'Download',
downloading_: 'Downloading...',
view: 'View',
autodetect_fields: 'Autodetect fields',
payment_link: 'Payment link',
strikeout: 'Strikeout',
draw_strikethrough_the_document: 'Draw strikethrough the document',
@@ -184,6 +188,9 @@ const en = {
}
const es = {
analyzing_: 'Analizando...',
download: 'Descargar',
downloading_: 'Descargando...',
view: 'Vista',
payment_link: 'Enlace de pago',
strikeout: 'Tachar',
@@ -369,6 +376,9 @@ const es = {
}
const it = {
analyzing_: 'Analisi...',
download: 'Scarica',
downloading_: 'Download in corso...',
view: 'Vista',
payment_link: 'Link di pagamento',
strikeout: 'Barrato',
@@ -554,6 +564,9 @@ const it = {
}
const pt = {
analyzing_: 'Analisando...',
download: 'Baixar',
downloading_: 'Baixando...',
view: 'Visualizar',
payment_link: 'Link de pagamento',
strikeout: 'Tachado',
@@ -739,6 +752,9 @@ const pt = {
}
const fr = {
analyzing_: 'Analyse...',
download: 'Télécharger',
downloading_: 'Téléchargement...',
view: 'Voir',
payment_link: 'Lien de paiement',
strikeout: 'Rature',
@@ -924,6 +940,9 @@ const fr = {
}
const de = {
analyzing_: 'Analysiere...',
download: 'Download',
downloading_: 'Download...',
view: 'Anzeigen',
payment_link: 'Zahlungslink',
strikeout: 'Durchstreichen',
@@ -1109,6 +1128,9 @@ const de = {
}
const nl = {
analyzing_: 'Analyseren...',
download: 'Downloaden',
downloading_: 'Downloaden...',
view: 'Bekijken',
payment_link: 'Betaallink',
strikeout: 'Doorhalen',
+2 -1
View File
@@ -1,10 +1,11 @@
<% align = field.dig('preferences', 'align') %>
<% valign = field.dig('preferences', 'valign') %>
<% color = field.dig('preferences', 'color') %>
<% bg_color = field.dig('preferences', 'background') %>
<% font = field.dig('preferences', 'font') %>
<% 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 } %>
<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? %>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" 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' %>
<% is_narrow = area['h']&.positive? && (area['w'].to_f / area['h']) > 6 %>
<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' %>">
+2 -2
View File
@@ -81,7 +81,7 @@
<% document = @submission.schema_documents.find { |a| item['attachment_uuid'] == a.uuid } %>
<% if document.preview_images.first %>
<scroll-to data-selector-id="page-<%= document.uuid %>-0" class="block cursor-pointer">
<img src="<%= Docuseal::URL_CACHE.fetch([document.id, document.uuid, 0].join(':'), expires_in: 10.minutes) { document.preview_images.first.url } %>" width="<%= document.preview_images.first.metadata['width'] %>" height="<%= document.preview_images.first.metadata['height'] %>" class="rounded border" loading="lazy">
<img src="<%= (document.preview_images.find { |e| e.filename.base.to_i.zero? } || document.preview_images.first).url %>" width="<%= document.preview_images.first.metadata['width'] %>" height="<%= document.preview_images.first.metadata['height'] %>" class="rounded border" loading="lazy">
<div class="pb-2 pt-1.5 text-center" dir="auto">
<%= item['name'].presence || document.filename.base %>
</div>
@@ -103,7 +103,7 @@
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_uuid, "#{index}.jpg")) %>
<page-container id="<%= "page-#{document.uuid}-#{index}" %>" class="block before:border before:absolute before:top-0 before:bottom-0 before:left-0 before:right-0 before:rounded relative mb-4" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>">
<img loading="lazy" src="<%= Docuseal::URL_CACHE.fetch([document.id, document.uuid, index].join(':'), expires_in: 10.minutes) { page.url } %>" width="<%= width %>" class="rounded" height="<%= height %>">
<img loading="lazy" src="<%= page.url %>" width="<%= width %>" class="rounded" height="<%= height %>">
<div class="top-0 bottom-0 left-0 right-0 absolute">
<% document_annots_index[index]&.each do |annot| %>
<%= render 'submissions/annotation', annot: %>
@@ -38,14 +38,6 @@
<%= f.button button_title(title: t('send_copy_to_email'), disabled_with: t('starting')), class: 'base-button' %>
</toggle-submit>
<% end %>
<% if Docuseal.multitenant? %>
<div class="divider uppercase"><%= t('or') %></div>
<% end %>
<% end %>
<% if Docuseal.multitenant? %>
<div>
<%= link_to t('create_free_account'), registration_path, class: 'white-button w-full' %>
</div>
<% end %>
</div>
</div>
+1 -1
View File
@@ -6,4 +6,4 @@
<%= button_to nil, user_configs_path, method: :post, params: { user_config: { key: UserConfig::SHOW_APP_TOUR, value: true } }, class: 'hidden', id: 'start_tour_button' %>
<% end %>
<% end %>
<template-builder class="grid" data-template="<%= @template_data %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
<template-builder class="grid" data-template="<%= @template_data %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
+2 -1
View File
@@ -98,8 +98,9 @@ Rails.application.routes.draw do
resources :submissions_filters, only: %i[show], param: 'name'
resources :templates, only: %i[new create edit update show destroy] do
resource :debug, only: %i[show], controller: 'templates_debug' if Rails.env.development?
resources :documents, only: %i[create], controller: 'template_documents'
resources :documents, only: %i[index create], controller: 'template_documents'
resources :clone_and_replace, only: %i[create], controller: 'templates_clone_and_replace'
resources :detect_fields, only: %i[create], controller: 'templates_detect_fields' unless Docuseal.multitenant?
resources :restore, only: %i[create], controller: 'templates_restore'
resources :archived, only: %i[index], controller: 'templates_archived_submissions'
resources :submissions, only: %i[new create]
+160
View File
@@ -25,6 +25,8 @@ class Pdfium
typedef :pointer, :FPDF_BITMAP
typedef :pointer, :FPDF_FORMHANDLE
typedef :pointer, :FPDF_TEXTPAGE
typedef :pointer, :FPDF_PAGEOBJECT
typedef :pointer, :FPDF_PATHSEGMENT
MAX_SIZE = 32_767
@@ -37,6 +39,9 @@ class Pdfium
FPDF_RENDER_FORCEHALFTONE = 0x400
FPDF_PRINTING = 0x800
TextNode = Struct.new(:content, :x, :y, :w, :h, keyword_init: true)
LineNode = Struct.new(:x, :y, :w, :h, :tilt, keyword_init: true)
# rubocop:disable Naming/ClassAndModuleCamelCase
class FPDF_LIBRARY_CONFIG < FFI::Struct
layout :version, :int,
@@ -77,6 +82,37 @@ class Pdfium
attach_function :FPDFText_ClosePage, [:FPDF_TEXTPAGE], :void
attach_function :FPDFText_CountChars, [:FPDF_TEXTPAGE], :int
attach_function :FPDFText_GetText, %i[FPDF_TEXTPAGE int int pointer], :int
attach_function :FPDFText_GetUnicode, %i[FPDF_TEXTPAGE int], :uint
attach_function :FPDFText_GetCharBox, %i[FPDF_TEXTPAGE int pointer pointer pointer pointer], :int
attach_function :FPDFText_GetCharOrigin, %i[FPDF_TEXTPAGE int pointer pointer], :int
attach_function :FPDFText_GetCharIndexAtPos, %i[FPDF_TEXTPAGE double double double double], :int
attach_function :FPDFText_CountRects, %i[FPDF_TEXTPAGE int int], :int
attach_function :FPDFText_GetRect, %i[FPDF_TEXTPAGE int pointer pointer pointer pointer], :int
attach_function :FPDFText_GetFontSize, %i[FPDF_TEXTPAGE int], :double
# Page object functions for extracting paths/lines
attach_function :FPDFPage_CountObjects, [:FPDF_PAGE], :int
attach_function :FPDFPage_GetObject, %i[FPDF_PAGE int], :FPDF_PAGEOBJECT
attach_function :FPDFPageObj_GetType, [:FPDF_PAGEOBJECT], :int
attach_function :FPDFPageObj_GetBounds, %i[FPDF_PAGEOBJECT pointer pointer pointer pointer], :int
attach_function :FPDFPath_CountSegments, [:FPDF_PAGEOBJECT], :int
attach_function :FPDFPath_GetPathSegment, %i[FPDF_PAGEOBJECT int], :FPDF_PATHSEGMENT
attach_function :FPDFPathSegment_GetType, [:FPDF_PATHSEGMENT], :int
attach_function :FPDFPathSegment_GetPoint, %i[FPDF_PATHSEGMENT pointer pointer], :int
# Page object types
FPDF_PAGEOBJ_UNKNOWN = 0
FPDF_PAGEOBJ_TEXT = 1
FPDF_PAGEOBJ_PATH = 2
FPDF_PAGEOBJ_IMAGE = 3
FPDF_PAGEOBJ_SHADING = 4
FPDF_PAGEOBJ_FORM = 5
# Path segment types
FPDF_SEGMENT_UNKNOWN = -1
FPDF_SEGMENT_LINETO = 0
FPDF_SEGMENT_BEZIERTO = 1
FPDF_SEGMENT_MOVETO = 2
typedef :int, :FPDF_BOOL
typedef :pointer, :IPDF_JSPLATFORM
@@ -157,6 +193,7 @@ class Pdfium
raise PdfiumError, "#{context_message}: #{error_message(error_code)} (Code: #{error_code})"
end
# rubocop:disable Metrics
class Document
attr_reader :document_ptr, :form_handle
@@ -386,6 +423,128 @@ class Pdfium
Pdfium.FPDFText_ClosePage(text_page) if text_page && !text_page.null?
end
def text_nodes
return @text_nodes if @text_nodes
text_page = Pdfium.FPDFText_LoadPage(page_ptr)
char_count = Pdfium.FPDFText_CountChars(text_page)
@text_nodes = []
return @text_nodes if char_count.zero?
char_count.times do |i|
unicode = Pdfium.FPDFText_GetUnicode(text_page, i)
char = [unicode].pack('U*')
left_ptr = FFI::MemoryPointer.new(:double)
right_ptr = FFI::MemoryPointer.new(:double)
bottom_ptr = FFI::MemoryPointer.new(:double)
top_ptr = FFI::MemoryPointer.new(:double)
result = Pdfium.FPDFText_GetCharBox(text_page, i, left_ptr, right_ptr, bottom_ptr, top_ptr)
next if result.zero?
left = left_ptr.read_double
right = right_ptr.read_double
origin_x_ptr = FFI::MemoryPointer.new(:double)
origin_y_ptr = FFI::MemoryPointer.new(:double)
Pdfium.FPDFText_GetCharOrigin(text_page, i, origin_x_ptr, origin_y_ptr)
origin_y = origin_y_ptr.read_double
font_size = Pdfium.FPDFText_GetFontSize(text_page, i)
font_size = 8 if font_size == 1
abs_x = left
abs_y = height - origin_y - (font_size * 0.8)
abs_width = right - left
abs_height = font_size
x = abs_x / width
y = abs_y / height
node_width = abs_width / width
node_height = abs_height / height
@text_nodes << TextNode.new(content: char, x: x, y: y, w: node_width, h: node_height)
end
@text_nodes = @text_nodes.sort { |a, b| a.y == b.y ? a.x <=> b.x : a.y <=> b.y }
ensure
Pdfium.FPDFText_ClosePage(text_page) if text_page && !text_page.null?
end
def line_nodes
return @line_nodes if @line_nodes
ensure_not_closed!
@line_nodes = []
object_count = Pdfium.FPDFPage_CountObjects(page_ptr)
return @line_nodes if object_count.zero?
object_count.times do |i|
page_object = Pdfium.FPDFPage_GetObject(page_ptr, i)
next if page_object.null?
obj_type = Pdfium.FPDFPageObj_GetType(page_object)
next unless obj_type == Pdfium::FPDF_PAGEOBJ_PATH
left_ptr = FFI::MemoryPointer.new(:float)
bottom_ptr = FFI::MemoryPointer.new(:float)
right_ptr = FFI::MemoryPointer.new(:float)
top_ptr = FFI::MemoryPointer.new(:float)
Pdfium.FPDFPageObj_GetBounds(page_object, left_ptr, bottom_ptr, right_ptr, top_ptr)
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 < 1 && obj_height < 1
segment_count = Pdfium.FPDFPath_CountSegments(page_object)
next if segment_count < 2
next unless segment_count <= 10 && (obj_height < 10 || obj_width < 10)
if obj_width > obj_height && obj_height < 10
tilt = 0
elsif obj_height > obj_width && obj_width < 10
tilt = 90
else
next
end
x = obj_left
y = obj_bottom
w = obj_width
h = obj_height
norm_x = x / width
norm_y = (height - y - h) / height
norm_w = w / width
norm_h = h / height
@line_nodes << LineNode.new(x: norm_x, y: norm_y, w: norm_w, h: norm_h, tilt: tilt)
end
@line_nodes = @line_nodes.sort { |a, b| a.y == b.y ? a.x <=> b.x : a.y <=> b.y }
end
def close
return if closed?
@@ -445,4 +604,5 @@ class Pdfium
at_exit do
cleanup_library
end
# rubocop:enable Metrics
end
+1
View File
@@ -39,6 +39,7 @@ Puma::Plugin.create do
configs = Sidekiq.configure_embed do |config|
config.logger.level = Logger::INFO
sidekiq_config = YAML.load_file('config/sidekiq.yml')
sidekiq_config['queues'] << 'fields' if ENV['DEMO'] == 'true'
config.queues = sidekiq_config['queues']
config.concurrency = ENV.fetch('SIDEKIQ_THREADS', 5).to_i
config.merge!(sidekiq_config)
+38 -18
View File
@@ -244,6 +244,7 @@ module Submissions
font_size ||= (([page.box.width, page.box.height].min / A4_SIZE[0].to_f) * FONT_SIZE).to_i
fill_color = field.dig('preferences', 'color').to_s.delete_prefix('#').presence
bg_color = field.dig('preferences', 'background').to_s.delete_prefix('#').presence
font_name = field.dig('preferences', 'font')
font_variant = (field.dig('preferences', 'font_type').presence || 'none').to_sym
@@ -289,6 +290,13 @@ module Submissions
with_signature_id = field['preferences']['with_signature_id']
end
if bg_color.present?
canvas.fill_color(bg_color)
.rectangle(area['x'] * width, height - (area['y'] * height) - (area['h'] * height),
area['w'] * width, area['h'] * height)
.fill
end
case field_type
when ->(type) { type == 'signature' && (with_signature_id || field.dig('preferences', 'reason_field_uuid')) }
attachment = submitter.attachments.find { |a| a.uuid == value }
@@ -321,9 +329,9 @@ module Submissions
end
end
reason_text = HexaPDF::Layout::TextFragment.create(reason_string,
font:,
font_size: (font_size / 1.8).to_i)
base_font_size = (font_size / 1.8).to_i
result = nil
if area['h']&.positive? && (area['w'].to_f / area['h']) > 6
area_x = area['x'] * width
@@ -344,12 +352,10 @@ module Submissions
id_string = "ID: #{attachment.uuid}".upcase
while true
text = HexaPDF::Layout::TextFragment.create(id_string,
font:,
font_size: (font_size / 1.8).to_i)
loop do
text = HexaPDF::Layout::TextFragment.create(id_string, font:, font_size: base_font_size)
result = layouter.fit([text], half_width, (font_size / 1.8) / 0.65)
result = layouter.fit([text], half_width, base_font_size / 0.65)
break if result.status == :success
@@ -358,25 +364,39 @@ module Submissions
break if id_string.length < 8
end
string = [id_string, reason_string].join("\n")
loop do
text = HexaPDF::Layout::TextFragment.create(string, font:, font_size: base_font_size)
result = layouter.fit([text], half_width, area_h)
break if result.status == :success
base_font_size *= 0.9
break if base_font_size < 2
end
text = HexaPDF::Layout::TextFragment.create(string, font:, font_size: base_font_size)
text_x = area_x + half_width
text_y = height - area_y
reason_result = layouter.fit([reason_text], half_width, height)
layouter.fit([text], half_width, (font_size / 1.8) / 0.65)
.draw(canvas, text_x + TEXT_LEFT_MARGIN, text_y)
layouter.fit([reason_text], half_width, reason_result.lines.sum(&:height))
.draw(canvas, text_x + TEXT_LEFT_MARGIN, text_y - TEXT_TOP_MARGIN - result.lines.sum(&:height))
layouter.fit([text], half_width, area_h).draw(canvas, text_x + TEXT_LEFT_MARGIN, text_y)
else
reason_text = HexaPDF::Layout::TextFragment.create(reason_string,
font:,
font_size: base_font_size)
id_string = "ID: #{attachment.uuid}".upcase
loop do
text = HexaPDF::Layout::TextFragment.create(id_string,
font:,
font_size: (font_size / 1.8).to_i)
font_size: base_font_size)
result = layouter.fit([text], area['w'] * width, (font_size / 1.8) / 0.65)
result = layouter.fit([text], area['w'] * width, base_font_size / 0.65)
break if result.status == :success
@@ -395,7 +415,7 @@ module Submissions
io = StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png'))
layouter.fit([text], area['w'] * width, (font_size / 1.8) / 0.65)
layouter.fit([text], area['w'] * width, base_font_size / 0.65)
.draw(canvas, (area['x'] * width) + TEXT_LEFT_MARGIN,
height - (area['y'] * height) - TEXT_TOP_MARGIN - image_height)
+17
View File
@@ -13,6 +13,17 @@ module Submitters
UnableToSendCode = Class.new(StandardError)
InvalidOtp = Class.new(StandardError)
MaliciousFileExtension = Class.new(StandardError)
DANGEROUS_EXTENSIONS = Set.new(%w[
exe com bat cmd scr pif vbs vbe js jse wsf wsh msi msp
hta cpl jar app deb rpm dmg pkg mpkg dll so dylib sys
inf reg ps1 psm1 psd1 ps1xml psc1 pssc bat cmd vb vba
sh bash zsh fish run out bin elf gadget workflow lnk scf
url desktop application action workflow apk ipa xap appx
appxbundle msix msixbundle diagcab diagpkg cpl msc ocx
drv scr ins isp mst paf prf shb shs slk ws wsc inf1 inf2
].freeze)
module_function
@@ -111,6 +122,12 @@ module Submitters
def create_attachment!(submitter, params)
blob =
if (file = params[:file])
extension = File.extname(file.original_filename).delete_prefix('.').downcase
if DANGEROUS_EXTENSIONS.include?(extension)
raise MaliciousFileExtension, "File type '.#{extension}' is not allowed."
end
ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
content_type: file.content_type)
+1 -1
View File
@@ -94,7 +94,7 @@ module Submitters
end
def fetch_fields(template, submitter_name: nil, for_submitter: nil)
if submitter_name
if submitter_name && !for_submitter
submitter =
template.submitters.find { |e| e['name'] == submitter_name } ||
raise(UnknownSubmitterName,
+485
View File
@@ -0,0 +1,485 @@
# frozen_string_literal: true
module Templates
module DetectFields
module_function
TextFieldBox = Struct.new(:x, :y, :w, :h, keyword_init: true)
PageNode = Struct.new(:prev, :next, :elem, :page, :attachment_uuid, keyword_init: true)
DATE_REGEXP = /
(?:
date
| signed\sat
| datum
)
\s*[:-]?\s*\z
/ix
NUMBER_REGEXP = /
(?:
price
| \$
| €
| total
| quantity
| prix
| quantité
| preis
| summe
| gesamt(?:betrag)?
| menge
| anzahl
| stückzahl
)
\s*[:-]?\s*\z
/ix
SIGNATURE_REGEXP = /
(?:
signature
| sign\shere
| sign
| signez\sici
| signer\sici
| unterschrift
| unterschreiben
| unterzeichnen
)
\s*[:-]?\s*\z
/ix
# rubocop:disable Metrics, Style
def call(io, attachment: nil, confidence: 0.3, temperature: 1, inference: Templates::ImageToFields,
nms: 0.1, split_page: false, aspect_ratio: true, padding: 20, regexp_type: true, &)
fields, head_node =
if attachment&.image?
process_image_attachment(io, attachment:, confidence:, nms:, split_page:, inference:,
temperature:, aspect_ratio:, padding:, &)
else
process_pdf_attachment(io, attachment:, confidence:, nms:, split_page:, inference:,
temperature:, aspect_ratio:, regexp_type:, padding:, &)
end
[fields, head_node]
end
def process_image_attachment(io, attachment:, confidence:, nms:, temperature:, inference:,
split_page: false, aspect_ratio: false, padding: nil)
image = Vips::Image.new_from_buffer(io.read, '')
fields = inference.call(image, confidence:, nms:, split_page:,
temperature:, aspect_ratio:, padding:)
fields = fields.map do |f|
{
uuid: SecureRandom.uuid,
type: f.type,
required: f.type != 'checkbox',
preferences: {},
areas: [{
x: f.x,
y: f.y,
w: f.w,
h: f.h,
page: 0,
attachment_uuid: attachment&.uuid
}]
}
end
yield [attachment&.uuid, 0, fields] if block_given?
[fields, nil]
end
def process_pdf_attachment(io, attachment:, confidence:, nms:, temperature:, inference:,
split_page: false, aspect_ratio: false, padding: nil, regexp_type: false)
doc = Pdfium::Document.open_bytes(io.read)
head_node = PageNode.new(elem: ''.b, page: 0, attachment_uuid: attachment&.uuid)
tail_node = head_node
fields = doc.page_count.times.flat_map do |page_number|
page = doc.get_page(page_number)
data, width, height = page.render_to_bitmap(width: inference::RESOLUTION * 1.5)
image = Vips::Image.new_from_memory(data, width, height, 4, :uchar)
fields = inference.call(image, confidence: confidence / 4.0, nms:, split_page:,
temperature:, aspect_ratio:, padding:)
text_fields = extract_text_fields_from_page(page)
line_fields = extract_line_fields_from_page(page)
fields = increase_confidence_for_overlapping_fields(fields, text_fields)
fields = increase_confidence_for_overlapping_fields(fields, line_fields)
fields = fields.reject { |f| f.confidence < confidence }
field_nodes, tail_node = build_page_nodes(page, fields, tail_node, attachment_uuid: attachment&.uuid)
fields = field_nodes.map do |node|
field = node.elem
type = regexp_type ? type_from_page_node(node) : field.type
{
uuid: SecureRandom.uuid,
type:,
required: type != 'checkbox',
preferences: {},
areas: [{
x: field.x, y: field.y,
w: field.w, h: field.h,
page: page_number,
attachment_uuid: attachment&.uuid
}]
}
end
yield [attachment&.uuid, page_number, fields] if block_given?
fields
ensure
page.close
end
print_debug(head_node) if Rails.env.development?
[fields, head_node]
ensure
doc.close
end
def print_debug(head_node)
current_node = head_node
index = 0
string = ''.b
loop do
string <<
if current_node.elem.is_a?(String)
current_node.elem
else
"[#{current_node.elem.type == 'checkbox' ? 'Checkbox' : 'Field'}_#{index += 1}]"
end
current_node = current_node.next
break unless current_node
end
Rails.logger.info(string)
end
def type_from_page_node(node)
return node.elem.type unless node.prev.elem.is_a?(String)
return node.elem.type unless node.elem.type == 'text'
string = node.prev.elem
return 'date' if string.match?(DATE_REGEXP)
return 'signature' if string.match?(SIGNATURE_REGEXP)
return 'number' if string.match?(NUMBER_REGEXP)
return 'text'
end
def build_page_nodes(page, fields, tail_node, attachment_uuid: nil)
field_nodes = []
current_text = ''.b
text_nodes = page.text_nodes
text_idx = 0
field_idx = 0
while text_idx < text_nodes.length || field_idx < fields.length
text_node = text_nodes[text_idx]
field = fields[field_idx]
process_text_node = false
process_field_node = false
if text_node && field
text_y_center = text_node.y + (text_node.h / 2.0)
field_y_center = field.y + (field.h / 2.0)
y_threshold = text_node.h / 2.0
vertical_distance = (text_y_center - field_y_center).abs
if vertical_distance < y_threshold
is_underscore = text_node.content == '_'
is_left_of_field = text_node.x < field.x
if is_underscore && is_left_of_field
text_x_end = text_node.x + text_node.w
distance = field.x - text_x_end
proximity_threshold = text_node.w * 3.0
if distance < proximity_threshold
process_field_node = true
else
process_text_node = true
end
elsif is_left_of_field
process_text_node = true
else
process_field_node = true
end
elsif text_node.y < field.y
process_text_node = true
else
process_field_node = true
end
elsif text_node
process_text_node = true
elsif field
process_field_node = true
end
if process_field_node
unless current_text.empty?
new_text_node = PageNode.new(prev: tail_node, elem: current_text, page: page.page_index, attachment_uuid:)
tail_node.next = new_text_node
tail_node = new_text_node
current_text = ''.b
end
new_field_node = PageNode.new(prev: tail_node, elem: field, page: page.page_index, attachment_uuid:)
tail_node.next = new_field_node
tail_node = new_field_node
field_nodes << tail_node
while text_idx < text_nodes.length
text_node_to_check = text_nodes[text_idx]
is_part_of_field = false
if text_node_to_check.content == '_'
check_y_center = text_node_to_check.y + (text_node_to_check.h / 2.0)
check_y_dist = (check_y_center - field_y_center).abs
check_y_thresh = text_node_to_check.h / 2.0
if check_y_dist < check_y_thresh
padding = text_node_to_check.w * 3.0
field_x_start = field.x - padding
field_x_end = field.x + field.w + padding
text_x_start = text_node_to_check.x
text_x_end = text_node_to_check.x + text_node_to_check.w
is_part_of_field = true if text_x_start <= field_x_end && field_x_start <= text_x_end
end
end
break unless is_part_of_field
text_idx += 1
end
field_idx += 1
elsif process_text_node
if text_idx > 0
prev_text_node = text_nodes[text_idx - 1]
x_gap = text_node.x - (prev_text_node.x + prev_text_node.w)
gap_w = text_node.w > prev_text_node.w ? text_node.w : prev_text_node.w
current_text << ' ' if x_gap > gap_w * 2
end
current_text << text_node.content
text_idx += 1
end
end
unless current_text.empty?
new_text_node = PageNode.new(prev: tail_node, elem: current_text, page: page.page_index, attachment_uuid:)
tail_node.next = new_text_node
tail_node = new_text_node
end
[field_nodes, tail_node]
end
def extract_line_fields_from_page(page)
line_thickness = 5.0 / page.height
vertical_lines, all_horizontal_lines = page.line_nodes.partition { |line| line.tilt == 90 }
horizontal_lines = all_horizontal_lines.reject do |h_line|
next true if h_line.w > 0.7 && (h_line.h < 0.1 || h_line.h < 0.9)
next false if vertical_lines.blank?
h_x_min = h_line.x
h_x_max = h_line.x + h_line.w
h_y_avg = h_line.y + (h_line.h / 2)
vertical_lines.any? do |v_line|
v_x_avg = v_line.x + (v_line.w / 2)
v_y_min = v_line.y
v_y_max = v_line.y + v_line.h
h_x_min_expanded = h_x_min - line_thickness
h_x_max_expanded = h_x_max + line_thickness
h_y_min_expanded = h_y_avg - line_thickness
h_y_max_expanded = h_y_avg + line_thickness
v_x_min_expanded = v_x_avg - line_thickness
v_x_max_expanded = v_x_avg + line_thickness
v_y_min_expanded = v_y_min - line_thickness
v_y_max_expanded = v_y_max + line_thickness
x_overlap = v_x_min_expanded <= h_x_max_expanded && v_x_max_expanded >= h_x_min_expanded
y_overlap = h_y_min_expanded <= v_y_max_expanded && h_y_max_expanded >= v_y_min_expanded
x_overlap && y_overlap
end
end
node_index = 0
horizontal_lines = horizontal_lines.reject do |line|
nodes = []
loop do
node = page.text_nodes[node_index += 1]
break unless node
break if node.y > line.y
next if node.x + node.w < line.x || line.x + line.w < node.x ||
node.y + node.h < line.y - node.h || line.y < node.y
nodes << node
next if nodes.blank?
next_node = page.text_nodes[node_index + 1]
break unless next_node
break if next_node.x + next_node.w < line.x || line.x + line.w < next_node.x ||
next_node.y + next_node.h < line.y - next_node.h || line.y < next_node.y
end
next if nodes.blank?
width = nodes.last.x + nodes.last.w - nodes.first.x
next true if width > line.w / 2.0
end
horizontal_lines.each do |line|
line.h += 4 * line_thickness
line.y -= 4 * line_thickness
end
end
def extract_text_fields_from_page(page)
text_nodes = page.text_nodes
field_boxes = []
i = 0
while i < text_nodes.length
node = text_nodes[i]
next i += 1 if node.content != '_'
x1 = node.x
y1 = node.y
x2 = node.x + node.w
y2 = node.y + node.h
underscore_count = 1
j = i + 1
while j < text_nodes.length
next_node = text_nodes[j]
break unless next_node.content == '_'
distance = next_node.x - x2
height_diff = (next_node.y - y1).abs
break if distance > 0.02 || height_diff > node.h * 0.5
underscore_count += 1
next_x2 = next_node.x + next_node.w
next_y2 = next_node.y + next_node.h
x2 = next_x2
y2 = [y2, next_y2].max
y1 = [y1, next_node.y].min
j += 1
end
field_boxes << TextFieldBox.new(x: x1, y: y1, w: x2 - x1, h: y2 - y1) if underscore_count >= 2
i = j
end
field_boxes
end
def calculate_iou(box1, box2)
x1 = [box1.x, box2.x].max
y1 = [box1.y, box2.y].max
x2 = [box1.x + box1.w, box2.x + box2.w].min
y2 = [box1.y + box1.h, box2.y + box2.h].min
intersection_width = [0, x2 - x1].max
intersection_height = [0, y2 - y1].max
intersection_area = intersection_width * intersection_height
return 0.0 if intersection_area.zero?
box1_area = box1.w * box1.h
box2_area = box2.w * box2.h
union_area = box1_area + box2_area - intersection_area
intersection_area / union_area
end
def boxes_overlap?(box1, box2)
!(box1.x + box1.w < box2.x || box2.x + box2.w < box1.x ||
box1.y + box1.h < box2.y || box2.y + box2.h < box1.y)
end
def increase_confidence_for_overlapping_fields(image_fields, text_fields, by: 1.0)
return image_fields if text_fields.blank?
image_fields.map do |image_field|
next if image_field.type != 'text'
field_bottom = image_field.y + image_field.h
text_fields.each do |text_field|
break if text_field.y > field_bottom
next if text_field.y + text_field.h < image_field.y
next unless boxes_overlap?(image_field, text_field) && calculate_iou(image_field, text_field) > 0.5
break image_field.confidence += by
end
end
image_fields
end
# rubocop:enable Metrics, Style
end
end
+340
View File
@@ -0,0 +1,340 @@
# frozen_string_literal: true
module Templates
module ImageToFields
module_function
Field = Struct.new(:type, :x, :y, :w, :h, :confidence, keyword_init: true)
MODEL_PATH = Rails.root.join('tmp/model.onnx')
RESOLUTION = 704
ID_TO_CLASS = %w[text checkbox].freeze
MEAN = [0.485, 0.456, 0.406].freeze
STD = [0.229, 0.224, 0.225].freeze
CPU_THREADS = Etc.nprocessors
# rubocop:disable Metrics
def call(image, confidence: 0.3, nms: 0.1, temperature: 1,
split_page: false, aspect_ratio: true, padding: nil, resolution: RESOLUTION)
base_image = image.extract_band(0, n: 3)
trimmed_base, base_offset_x, base_offset_y = trim_image_with_padding(base_image, padding)
if split_page && image.height > image.width
regions = build_split_image_regions(trimmed_base)
detections = { xyxy: Numo::SFloat[], confidence: Numo::SFloat[], class_id: Numo::Int32[] }
detections = regions.reduce(detections) do |acc, r|
next detections if r[:img].height <= 0 || r[:img].width <= 0
input_tensor, transform_info = preprocess_image(r[:img], resolution, aspect_ratio:)
transform_info[:trim_offset_x] = base_offset_x
transform_info[:trim_offset_y] = base_offset_y + r[:offset_y]
outputs = model.predict({ 'input' => input_tensor })
boxes = Numo::SFloat.cast(outputs['dets'])[0, true, true]
logits = Numo::SFloat.cast(outputs['labels'])[0, true, true]
postprocess_outputs(boxes, logits, transform_info, acc, confidence:, temperature:, resolution:)
end
else
input_tensor, transform_info = preprocess_image(trimmed_base, resolution, aspect_ratio:)
transform_info[:trim_offset_x] = base_offset_x
transform_info[:trim_offset_y] = base_offset_y
outputs = model.predict({ 'input' => input_tensor })
boxes = Numo::SFloat.cast(outputs['dets'])[0, true, true]
logits = Numo::SFloat.cast(outputs['labels'])[0, true, true]
detections = postprocess_outputs(boxes, logits, transform_info, confidence:, temperature:, resolution:)
end
detections = apply_nms(detections, nms)
fields = build_fields_from_detections(detections, image)
sort_fields(fields, y_threshold: 10.0 / image.height)
end
def build_split_image_regions(image)
half_h = image.height / 2
top_h = half_h
bottom_h = image.height - half_h
[
{ img: image.crop(0, 0, image.width, top_h), offset_y: 0 },
{ img: image.crop(0, top_h, image.width, bottom_h), offset_y: top_h }
]
end
def build_fields_from_detections(detections, image)
Array.new(detections[:xyxy].shape[0]) do |i|
x1 = detections[:xyxy][i, 0]
y1 = detections[:xyxy][i, 1]
x2 = detections[:xyxy][i, 2]
y2 = detections[:xyxy][i, 3]
class_id = detections[:class_id][i].to_i
confidence = detections[:confidence][i]
x0_norm = x1 / image.width.to_f
y0_norm = y1 / image.height.to_f
x1_norm = x2 / image.width.to_f
y1_norm = y2 / image.height.to_f
type_name = ID_TO_CLASS[class_id]
Field.new(
type: type_name,
x: x0_norm,
y: y0_norm,
w: (x1_norm - x0_norm),
h: (y1_norm - y0_norm),
confidence:
)
end
end
def trim_image_with_padding(image, padding = 0)
return [image, 0, 0] if padding.nil?
left, top, trim_width, trim_height = image.find_trim(threshold: 10, background: [255, 255, 255])
padded_left = [left - padding, 0].max
padded_top = [top - padding, 0].max
padded_right = [left + trim_width + padding, image.width].min
padded_bottom = [top + trim_height + padding, image.height].min
width = padded_right - padded_left
height = padded_bottom - padded_top
trimmed_image = image.crop(padded_left, padded_top, width, height)
[trimmed_image, padded_left, padded_top]
end
def preprocess_image(image, resolution, aspect_ratio: false)
scale_x = resolution.to_f / image.width
scale_y = resolution.to_f / image.height
if aspect_ratio
scale = [scale_x, scale_y].min
new_width = (image.width * scale).round
new_height = (image.height * scale).round
resized = image.resize(scale, vscale: scale, kernel: :lanczos3)
pad_x = ((resolution - new_width) / 2.0).round
pad_y = ((resolution - new_height) / 2.0).round
image = resized.embed(pad_x, pad_y, resolution, resolution, background: [255, 255, 255])
transform_info = { scale_x: scale, scale_y: scale, pad_x: pad_x, pad_y: pad_y }
else
image = image.resize(scale_x, vscale: scale_y, kernel: :lanczos3)
transform_info = { scale_x: scale_x, scale_y: scale_y, pad_x: 0, pad_y: 0 }
end
image /= 255.0
image = (image - MEAN) / STD
pixel_data = image.write_to_memory
img_array = Numo::SFloat.from_binary(pixel_data, [resolution, resolution, 3])
img_array = img_array.transpose(2, 0, 1)
[img_array.reshape(1, 3, resolution, resolution), transform_info]
end
def nms(boxes, scores, iou_threshold = 0.5)
return Numo::Int32[] if boxes.shape[0].zero?
x1 = boxes[true, 0]
y1 = boxes[true, 1]
x2 = boxes[true, 2]
y2 = boxes[true, 3]
areas = (x2 - x1) * (y2 - y1)
order = scores.sort_index.reverse
keep = []
while order.size.positive?
i = order[0]
keep << i
break if order.size == 1
xx1 = Numo::SFloat.maximum(x1[i], x1[order[1..]])
yy1 = Numo::SFloat.maximum(y1[i], y1[order[1..]])
xx2 = Numo::SFloat.minimum(x2[i], x2[order[1..]])
yy2 = Numo::SFloat.minimum(y2[i], y2[order[1..]])
w = Numo::SFloat.maximum(0.0, xx2 - xx1)
h = Numo::SFloat.maximum(0.0, yy2 - yy1)
intersection = w * h
iou = intersection / (areas[i] + areas[order[1..]] - intersection)
inds = iou.le(iou_threshold).where
order = order[inds + 1]
end
Numo::Int32.cast(keep)
end
def postprocess_outputs(boxes, logits, transform_info, detections = nil, confidence: 0.3, temperature: 1,
resolution: RESOLUTION)
scaled_logits = logits / temperature
probs = 1.0 / (1.0 + Numo::NMath.exp(-scaled_logits))
scores = probs.max(axis: 1)
labels = probs.argmax(axis: 1)
cx = boxes[true, 0]
cy = boxes[true, 1]
w = boxes[true, 2]
h = boxes[true, 3]
x1 = cx - (w / 2.0)
y1 = cy - (h / 2.0)
x2 = cx + (w / 2.0)
y2 = cy + (h / 2.0)
boxes_xyxy = Numo::SFloat.zeros(boxes.shape[0], 4)
boxes_xyxy[true, 0] = x1
boxes_xyxy[true, 1] = y1
boxes_xyxy[true, 2] = x2
boxes_xyxy[true, 3] = y2
boxes_xyxy *= resolution
pad_x = transform_info[:pad_x]
pad_y = transform_info[:pad_y]
boxes_xyxy[true, 0] -= pad_x
boxes_xyxy[true, 1] -= pad_y
boxes_xyxy[true, 2] -= pad_x
boxes_xyxy[true, 3] -= pad_y
scale_x = transform_info[:scale_x]
scale_y = transform_info[:scale_y]
boxes_xyxy[true, 0] /= scale_x
boxes_xyxy[true, 1] /= scale_y
boxes_xyxy[true, 2] /= scale_x
boxes_xyxy[true, 3] /= scale_y
trim_offset_x = transform_info[:trim_offset_x]
trim_offset_y = transform_info[:trim_offset_y]
boxes_xyxy[true, 0] += trim_offset_x
boxes_xyxy[true, 1] += trim_offset_y
boxes_xyxy[true, 2] += trim_offset_x
boxes_xyxy[true, 3] += trim_offset_y
keep_mask = scores.gt(confidence)
keep_indices = keep_mask.where
if keep_indices.empty?
detections || {
xyxy: Numo::SFloat[],
confidence: Numo::SFloat[],
class_id: Numo::Int32[]
}
else
scores = scores[keep_indices]
labels = labels[keep_indices]
boxes_xyxy = boxes_xyxy[keep_indices, true]
if detections
existing_n = detections[:xyxy].shape[0]
new_n = boxes_xyxy.shape[0]
total = existing_n + new_n
xyxy = Numo::SFloat.zeros(total, 4)
conf = Numo::SFloat.zeros(total)
cls = Numo::Int32.zeros(total)
if existing_n.positive?
xyxy[0...existing_n, true] = detections[:xyxy]
conf[0...existing_n] = detections[:confidence]
cls[0...existing_n] = detections[:class_id]
end
xyxy[existing_n...(existing_n + new_n), true] = boxes_xyxy
conf[existing_n...(existing_n + new_n)] = scores
cls[existing_n...(existing_n + new_n)] = Numo::Int32.cast(labels)
{ xyxy: xyxy, confidence: conf, class_id: cls }
else
{
xyxy: boxes_xyxy,
confidence: scores,
class_id: Numo::Int32.cast(labels)
}
end
end
end
def sort_fields(fields, y_threshold: 0.01)
sorted_fields = fields.sort { |a, b| a.y == b.y ? a.x <=> b.x : a.y <=> b.y }
lines = []
current_line = []
sorted_fields.each do |field|
if current_line.blank? || (field.y - current_line.first.y).abs < y_threshold
current_line << field
else
lines << current_line.sort_by(&:x)
current_line = [field]
end
end
lines << current_line.sort_by(&:x) if current_line.present?
lines.flatten
end
def apply_nms(detections, threshold = 0.5)
return detections if detections[:xyxy].shape[0].zero?
keep_indices = nms(detections[:xyxy], detections[:confidence], threshold)
{
xyxy: detections[:xyxy][keep_indices, true],
confidence: detections[:confidence][keep_indices],
class_id: detections[:class_id][keep_indices]
}
end
def model
@model ||= OnnxRuntime::Model.new(
MODEL_PATH.to_s,
inter_op_num_threads: CPU_THREADS,
intra_op_num_threads: CPU_THREADS,
enable_mem_pattern: false,
enable_cpu_mem_arena: false,
providers: ['CPUExecutionProvider']
)
end
# rubocop:enable Metrics
end
end