Compare commits

...
78 Commits
Author SHA1 Message Date
Alex Turchyn da44d51a3b Merge from docusealco/wip 2026-06-15 13:37:25 +03:00
Pete Matsyburka 73ce10b26a add cleanup document helper 2026-06-15 12:05:50 +03:00
Pete Matsyburka 13874b8830 add documents editor 2026-06-15 10:21:23 +03:00
Pete Matsyburka ac66809a05 fix date tz 2026-06-12 14:03:20 +03:00
Pete Matsyburka f5229d5dec fix zoom 2026-06-12 12:47:23 +03:00
Pete Matsyburka 222291d11d update gem 2026-06-09 21:51:06 +03:00
Pete Matsyburka 28f47e1093 handle template archived 2026-06-09 19:49:12 +03:00
Pete Matsyburka 7784346a97 update gem 2026-06-09 12:11:16 +03:00
Alex Turchyn ed46af8418 Merge from docusealco/wip 2026-06-08 11:56:14 +03:00
Pete Matsyburka 095f25a6e9 adjust image type 2026-06-08 09:31:38 +03:00
Pete Matsyburka 3cdab30dc8 remove oj 2026-06-05 16:55:55 +03:00
Pete Matsyburka 8e462047ba fix upload callback 2026-06-05 14:45:22 +03:00
Pete Matsyburka b7f8b65d9d adjust button style 2026-06-05 09:55:21 +03:00
Alex Turchyn da21c37254 add Google Drive replace option 2026-06-05 09:55:21 +03:00
Pete Matsyburka a89aef4a89 adjust zoom 2026-06-04 14:39:02 +03:00
Alex Turchyn dd6516fd11 add cmd+scroll zooming to template builder 2026-06-04 08:20:38 +03:00
Pete Matsyburka 344a3ce198 adjust sanitize 2026-06-03 13:24:08 +03:00
Pete Matsyburka 62f8283587 adjust html to text 2026-06-02 13:44:52 +03:00
Alex Turchyn 1f89accac3 Merge from docusealco/wip 2026-06-01 14:58:06 +03:00
Pete Matsyburka 9fcaef4cf7 use new_from_memory_copy 2026-05-31 14:28:35 +03:00
Pete Matsyburka d5738a0631 disable variant_processor 2026-05-31 12:51:52 +03:00
Pete Matsyburka 8bf7a1f95a adjust image detect fields 2026-05-31 09:20:51 +03:00
Pete Matsyburka 04cf36891e fix complete button press enter 2026-05-31 09:09:57 +03:00
Pete Matsyburka b2d9948c30 remove active storage analyzers 2026-05-31 08:06:52 +03:00
Pete Matsyburka a2c9ac1707 remove image_processing 2026-05-30 21:37:00 +03:00
Pete Matsyburka 46a6bd0108 fix stamp 2026-05-30 12:04:23 +03:00
Pete Matsyburka 5f069e7a40 use load vips 2026-05-30 07:49:40 +03:00
Pete Matsyburka ff57e5c6ae fix pdfa 2026-05-29 15:31:32 +03:00
Alex Turchyn f65b6e2d76 add confirm prompt for template upload via URL 2026-05-29 09:29:21 +03:00
Pete Matsyburka 58fd180ae0 update gem 2026-05-29 08:04:53 +03:00
Pete Matsyburka cd6503c4c3 adjust image size 2026-05-28 15:37:20 +03:00
Pete Matsyburka e8b36c2b6d update gh stars 2026-05-28 14:38:34 +03:00
Pete Matsyburka 51743f1359 fix spec 2026-05-28 10:18:45 +03:00
Pete Matsyburka 504c42646b refactor template builder data 2026-05-28 10:06:47 +03:00
Pete Matsyburka c61e84d1b4 adjust archive 2026-05-28 08:40:05 +03:00
Pete Matsyburka b8ab01c46c add dynamic documents to template response 2026-05-28 08:29:10 +03:00
Pete Matsyburka 9558060bde check resubmit config 2026-05-28 08:29:09 +03:00
Pete Matsyburka 0741c879f1 use expires_at with file links 2026-05-27 22:19:18 +03:00
Pete Matsyburka 2e6dd2867e file url ttl env var 2026-05-26 21:54:59 +03:00
Pete Matsyburka 9f3fcccb61 optimize build 2026-05-26 16:50:10 +03:00
Pete Matsyburka ae08d7abad adjust opacity_layer 2026-05-26 13:11:15 +03:00
Pete Matsyburka 16808a7ec6 use pngload_buffer 2026-05-26 13:11:15 +03:00
Pete Matsyburka b1dfcd6283 fix email typo 2026-05-26 13:11:15 +03:00
Pete Matsyburka 369f4c0f08 sync prefillable field changes 2026-05-26 13:11:15 +03:00
Pete Matsyburka 827de03579 fix result generatrion 2026-05-26 08:57:30 +03:00
Alex Turchyn 0c7f4e5b43 fix sidekiq embedded redis connection 2026-05-25 21:52:34 +03:00
Alex Turchyn 9c700a3fb6 Merge from docusealco/wip 2026-05-25 12:28:05 +03:00
Pete Matsyburka 89bf83febb adjust autorot 2026-05-23 11:43:28 +03:00
Pete Matsyburka d44b35cf0a convert images on upload 2026-05-23 08:25:04 +03:00
Pete Matsyburka 891833e273 refactor process doc 2026-05-22 14:06:04 +03:00
Pete Matsyburka 29d3a80d3f fix rubocop 2026-05-21 21:55:30 +03:00
Pete Matsyburka 90fef58684 ruby 4.0.5 2026-05-21 21:46:27 +03:00
Pete Matsyburka 07841722ac adjust image load 2026-05-21 21:44:13 +03:00
Pete Matsyburka 9e4da5948b convert images on upload 2026-05-21 14:50:30 +03:00
Pete Matsyburka dc6e4313a1 fix ci 2026-05-20 16:15:15 +03:00
Pete Matsyburka a68cc0b689 convert images on upload 2026-05-20 15:23:42 +03:00
Pete Matsyburka d057fb0f67 content type priority 2026-05-19 13:27:16 +03:00
Pete Matsyburka 7e8f045a29 cleanup 2026-05-19 11:42:14 +03:00
Pete Matsyburka 5249d8d18d update gem 2026-05-19 11:02:15 +03:00
Pete Matsyburka f0fafbadd4 isolate editor nodes 2026-05-19 10:52:48 +03:00
Pete Matsyburka 8b51079dcc update gem 2026-05-18 18:42:04 +03:00
Pete Matsyburka 354bccd6e8 handle dangerous extensions 2026-05-18 18:05:16 +03:00
Pete Matsyburka 6806772346 add security headers 2026-05-18 16:47:19 +03:00
Alex Turchyn 60082655d4 Merge from docusealco/wip 2026-05-18 13:45:07 +03:00
Pete Matsyburka 7fe56941fd optimize pdf images 2026-05-17 19:55:03 +03:00
Pete Matsyburka abd498dd33 adjust validation message 2026-05-16 08:15:02 +03:00
Pete Matsyburka 99ca0136ed add percent format 2026-05-16 08:15:02 +03:00
Pete Matsyburka e52830c9b4 add authorization checks 2026-05-16 08:15:01 +03:00
Pete Matsyburka 755decca27 adjust capture revision 2026-05-15 17:50:18 +03:00
Pete Matsyburka 41604008d1 clamp area box 2026-05-15 17:20:29 +03:00
Pete Matsyburka 5bddce8969 fix i18n 2026-05-14 12:16:36 +03:00
Pete Matsyburka b25503f141 fix i18n 2026-05-13 10:08:13 +03:00
Pete Matsyburka bdf1850448 resend emails 2026-05-13 09:59:06 +03:00
Pete Matsyburka 230d3ccf69 fix with download config 2026-05-13 08:52:41 +03:00
Pete Matsyburka e378025a2d fix refactor complete button 2026-05-12 22:24:09 +03:00
Alex Turchyn 04129ded90 add complete button to signing form header 2026-05-12 19:19:49 +03:00
Pete Matsyburka a7891f89f8 do not override custom webhook header 2026-05-11 17:13:26 +03:00
Pete Matsyburka 37d4a8e834 update gem 2026-05-11 14:06:17 +03:00
115 changed files with 5787 additions and 655 deletions

No files matched your search

+7 -7
View File
@@ -12,7 +12,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 4.0.1
ruby-version: 4.0.5
- name: Cache gems
uses: actions/cache@v4
with:
@@ -37,7 +37,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 4.0.1
ruby-version: 4.0.5
- name: Cache gems
uses: actions/cache@v4
with:
@@ -89,7 +89,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 4.0.1
ruby-version: 4.0.5
- name: Cache gems
uses: actions/cache@v4
with:
@@ -104,7 +104,7 @@ jobs:
bundle install --jobs 4 --retry 4
yarn install
sudo apt-get update
sudo apt-get install libvips
sudo apt-get install libvips liblept5
- name: Run Brakeman
run: bundle exec brakeman -q --exit-on-warn
@@ -132,7 +132,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 4.0.1
ruby-version: 4.0.5
- name: Set up Node
uses: actions/setup-node@v1
with:
@@ -162,8 +162,8 @@ jobs:
bundle install --jobs 4 --retry 4
yarn install
sudo apt-get update
sudo apt-get install -y libvips
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"
sudo apt-get install -y libvips liblept5
wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz"
sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so
rm -f pdfium-linux.tgz
- name: Run
+14 -8
View File
@@ -1,19 +1,23 @@
FROM ruby:4.0.1-alpine AS download
FROM ruby:4.0.5-alpine AS download
WORKDIR /fonts
RUN apk --no-cache add wget && \
RUN apk --no-cache add wget unzip && \
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && \
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && \
wget https://raw.githubusercontent.com/impallari/DancingScript/master/OFL.txt && \
wget https://raw.githubusercontent.com/notofonts/noto-fonts/refs/heads/main/LICENSE && \
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \
wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-musl-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260613/pdfium-musl-$(uname -m).zip" && \
case "$(uname -m)" in \
x86_64) echo "2c953ff72ee2dda07e7fc577e25841cc3d6464468a7c5adfaea574efcbc3b90b pdfium-linux.zip" ;; \
aarch64) echo "23bbe287d2753fdb05741c7660647eb0ef0d2e4da2ce0722bfa9d9d455bd64e2 pdfium-linux.zip" ;; \
esac | sha256sum -c - && \
mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux
unzip -q pdfium-linux.zip -d /pdfium-linux
FROM ruby:4.0.1-alpine AS webpack
FROM ruby:4.0.5-alpine AS webpack
ENV RAILS_ENV=production
ENV NODE_ENV=production
@@ -40,7 +44,7 @@ COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
FROM ruby:4.0.1-alpine AS app
FROM ruby:4.0.5-alpine AS app
ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test"
@@ -48,7 +52,8 @@ ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
WORKDIR /app
RUN apk add --no-cache libpq vips redis vips-heif onnxruntime
RUN apk add --no-cache libpq vips redis onnxruntime leptonica && \
rm -f /usr/bin/onnx_test_runner /usr/bin/onnxruntime_test
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
@@ -81,7 +86,7 @@ COPY --chown=docuseal:docuseal .version ./public/version
COPY --chown=docuseal:docuseal --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts/LICENSE /fonts/
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 /pdfium-linux/licenses/ /usr/lib/libpdfium-licenses/
COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx
COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs
@@ -94,6 +99,7 @@ WORKDIR /data/docuseal
ENV HOME=/home/docuseal
ENV WORKDIR=/data/docuseal
ENV VIPS_MAX_COORD=17000
ENV VIPS_BLOCK_UNTRUSTED=1
EXPOSE 3000
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
+1 -3
View File
@@ -2,7 +2,7 @@
source 'https://rubygems.org'
ruby '4.0.1'
ruby '4.0.5'
gem 'addressable'
gem 'arabic-letter-connector', require: false
@@ -21,11 +21,9 @@ gem 'faraday'
gem 'faraday-follow_redirects'
gem 'google-cloud-storage', require: false
gem 'hexapdf'
gem 'image_processing'
gem 'jwt', require: false
gem 'lograge'
gem 'numo-narray-alt', require: false
gem 'oj'
gem 'onnxruntime', require: false
gem 'pagy'
gem 'pg', require: false
+14 -25
View File
@@ -116,7 +116,7 @@ GEM
erubi (~> 1.4)
parser (>= 2.4)
smart_properties
bigdecimal (4.1.0)
bigdecimal (4.1.2)
bindex (0.8.1)
bootsnap (1.23.0)
msgpack (~> 1.2)
@@ -161,7 +161,7 @@ GEM
irb (~> 1.10)
reline (>= 0.3.8)
declarative (0.0.20)
devise (5.0.3)
devise (5.0.4)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 7.0)
@@ -195,7 +195,7 @@ GEM
railties (>= 6.1.0)
faker (3.6.1)
i18n (>= 1.8.11, < 2)
faraday (2.14.1)
faraday (2.14.2)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -264,18 +264,15 @@ GEM
strscan (>= 3.1.2)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
io-console (0.8.2)
irb (1.17.0)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.19.3)
jwt (3.1.2)
json (2.19.8)
jwt (3.2.0)
base64
language_server-protocol (3.17.0.5)
launchy (3.1.1)
@@ -308,17 +305,15 @@ GEM
marcel (1.1.0)
matrix (0.4.3)
method_source (1.1.0)
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
minitest (6.0.3)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.0)
multi_json (1.19.1)
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.6.4)
net-imap (0.6.4.1)
date
net-protocol
net-pop (0.1.2)
@@ -339,9 +334,6 @@ GEM
nokogiri (1.19.3-x86_64-linux-musl)
racc (~> 1.4)
numo-narray-alt (0.10.3)
oj (3.16.16)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
onnxruntime (0.10.1-aarch64-linux)
ffi
onnxruntime (0.10.1-arm64-darwin)
@@ -351,7 +343,6 @@ GEM
openssl (4.0.1)
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.3)
package_json (0.2.0)
pagy (43.4.4)
json
@@ -382,7 +373,7 @@ GEM
date
stringio
public_suffix (7.0.5)
puma (7.2.0)
puma (8.0.2)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.6)
@@ -429,12 +420,12 @@ GEM
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.1)
rake (13.4.2)
rdoc (7.2.0)
erb
psych (>= 4.0.0)
tsort
redis-client (0.28.0)
redis-client (0.29.0)
connection_pool
regexp_parser (2.11.3)
reline (0.6.3)
@@ -516,12 +507,12 @@ GEM
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
sidekiq (8.1.2)
sidekiq (8.1.6)
connection_pool (>= 3.0.0)
json (>= 2.16.0)
logger (>= 1.7.0)
rack (>= 3.2.0)
redis-client (>= 0.26.0)
redis-client (>= 0.29.0)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
@@ -624,12 +615,10 @@ DEPENDENCIES
foreman
google-cloud-storage
hexapdf
image_processing
jwt
letter_opener_web
lograge
numo-narray-alt
oj
onnxruntime
pagy
pg
@@ -662,7 +651,7 @@ DEPENDENCIES
webmock
RUBY VERSION
ruby 4.0.1
ruby 4.0.5
BUNDLED WITH
4.0.3
@@ -9,7 +9,9 @@ module Api
before_action :set_cors_headers
before_action :set_noindex_headers
before_action :set_security_headers
# rubocop:disable Metrics
def show
blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
@@ -21,6 +23,12 @@ module Api
blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid)
if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase)
Rollbar.error('Dangerous extension') if defined?(Rollbar)
return head :unprocessable_content
end
attachment = blob.attachments.take
@record = attachment.record
@@ -45,6 +53,7 @@ module Api
end
end
end
# rubocop:enable Metrics
private
@@ -9,6 +9,7 @@ module Api
before_action :set_cors_headers
before_action :set_noindex_headers
before_action :set_security_headers
# rubocop:disable Metrics
def show
@@ -18,6 +19,12 @@ module Api
return head :not_found unless blob
if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase)
Rollbar.error('Dangerous extension') if defined?(Rollbar)
return head :unprocessable_content
end
is_permitted = blob.attachments.any? do |a|
(current_user && a.record.account.id == current_user.account_id) ||
a.record.account.account_configs.any? { |e| e.key == 'legacy_blob_proxy' } ||
@@ -102,6 +102,10 @@ module Api
headers['X-Robots-Tag'] = 'noindex'
end
def set_security_headers
response.headers['X-Content-Type-Options'] = 'nosniff'
end
def set_cors_headers
headers['Access-Control-Allow-Origin'] = '*'
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
@@ -11,13 +11,13 @@ module Api
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
unless can_upload?(@submitter)
Rollbar.error("Can't upload: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
end
file = params[:file]
if params[:type].in?(%w[initials signature])
image = Vips::Image.new_from_file(params[:file].path)
image = ImageUtils.load_vips(file.read, content_type: file.content_type)
if ImageUtils.blank?(image)
Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar)
@@ -31,9 +31,11 @@ module Api
return render json: { error: "#{params[:type]} error, try to sign on another device" },
status: :unprocessable_content
end
metadata = { analyzed: true, identified: true, width: image.width, height: image.height }
end
attachment = Submitters.create_attachment!(@submitter, params)
attachment = Submitters.create_attachment!(@submitter, file, metadata:)
if params[:remember_signature] == 'true' && @submitter.email.present?
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
@@ -54,6 +54,12 @@ module Api
return render json: { error: 'Template not found' }, status: :unprocessable_content if @template.nil?
if @template.archived_at?
Rollbar.warning("Archived template submission: #{@template.id}") if defined?(Rollbar)
return render json: { error: 'Template has been archived' }, status: :unprocessable_content
end
if @template.fields.blank?
Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
+37 -14
View File
@@ -9,20 +9,7 @@ module Api
templates = paginate(templates.preload(:author, folder: :parent_folder))
schema_documents =
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
record_type: 'Template',
name: :documents,
uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') })
.preload(:blob)
preview_image_attachments =
ActiveStorage::Attachment.joins(:blob)
.where(blob: { filename: ['0.png', '0.jpg'] })
.where(record_id: schema_documents.map(&:id),
record_type: 'ActiveStorage::Attachment',
name: :preview_images)
.preload(:blob)
schema_documents, dynamic_documents, preview_image_attachments = preload_relations(templates)
expires_at = Accounts.link_expires_at(current_account)
@@ -30,6 +17,7 @@ module Api
data: templates.map do |t|
Templates::SerializeForApi.call(t,
schema_documents: schema_documents.select { |e| e.record_id == t.id },
dynamic_documents:,
preview_image_attachments:,
expires_at:)
end,
@@ -88,6 +76,41 @@ module Api
private
def preload_relations(templates)
schema_documents =
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
record_type: 'Template',
name: :documents,
uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') })
.preload(:blob)
dynamic_document_uuids =
templates.flat_map { |t| t.schema.select { |item| item['dynamic'] }.pluck('attachment_uuid') }
dynamic_documents =
if dynamic_document_uuids.present?
DynamicDocument.where(template: templates.map(&:id))
.where(uuid: dynamic_document_uuids)
.preload(current_version: { document_attachment: :blob })
.select(:id, :uuid, :template_id, :sha1, :created_at, :updated_at)
else
DynamicDocument.none
end
preview_attachment_ids =
schema_documents.map(&:id) + dynamic_documents.filter_map { |d| d.current_version&.document_attachment&.id }
preview_image_attachments =
ActiveStorage::Attachment.joins(:blob)
.where(blob: { filename: ['0.png', '0.jpg'] })
.where(record_id: preview_attachment_ids,
record_type: 'ActiveStorage::Attachment',
name: :preview_images)
.preload(:blob)
[schema_documents, dynamic_documents, preview_image_attachments]
end
def filter_templates(templates, params)
templates = Templates.search(current_user, templates, params[:q])
templates = params[:archived].in?(['true', true]) ? templates.archived : templates.active
+8 -1
View File
@@ -101,11 +101,18 @@ class StartFormController < ApplicationController
def load_resubmit_submitter
@resubmit_submitter =
if params[:resubmit].present? && !params[:resubmit].in?([true, 'true'])
Submitter.find_by(slug: params[:resubmit])
submitter = Submitter.find_by(slug: params[:resubmit])
submitter if submitter && can_resubmit?(submitter)
end
end
def can_resubmit?(submitter)
submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false
end
def authorize_start!
return redirect_to submit_form_path(@resubmit_submitter.slug) if @resubmit_submitter && @template.archived_at?
return redirect_to start_form_path(@template.slug) if @template.archived_at?
return if @resubmit_submitter
@@ -38,6 +38,8 @@ class SubmissionsController < ApplicationController
end
def create
return redirect_to template_path(@template), alert: I18n.t('template_has_been_archived') if @template.archived_at?
save_template_message(@template, params) if params[:save_message] == '1'
[params.delete(:subject), params.delete(:body)] if params[:is_custom_message] != '1'
@@ -0,0 +1,39 @@
# frozen_string_literal: true
class SubmissionsResendEmailController < ApplicationController
load_and_authorize_resource :submission
before_action do
authorize!(:manage, :resend_all)
authorize!(:update, @submission)
end
def create
submitters = @submission.submitters.reject(&:completed_at?).select { |s| s.email.present? && !s.declined_at? }
if Docuseal.multitenant?
recent_submitter_ids =
SubmissionEvent.where(submitter_id: submitters.map(&:id),
event_type: 'send_email',
created_at: 10.hours.ago..Time.current).pluck(:submitter_id).to_set
submitters = submitters.reject { |s| recent_submitter_ids.include?(s.id) }
end
submitters.each do |submitter|
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => submitter.id)
submitter.sent_at ||= Time.current
submitter.save!
end
notice =
if submitters.empty?
I18n.t('email_has_been_sent_already')
else
I18n.t('emails_have_been_sent_to_n_recipients', count: submitters.size)
end
redirect_back(fallback_location: submission_path(@submission), notice:)
end
end
@@ -4,6 +4,8 @@ class SubmissionsUnarchiveController < ApplicationController
load_and_authorize_resource :submission
def create
authorize!(:update, @submission)
@submission.update!(archived_at: nil)
redirect_to submission_path(@submission), notice: I18n.t('submission_has_been_unarchived')
@@ -4,6 +4,8 @@ class SubmittersSendEmailController < ApplicationController
load_and_authorize_resource :submitter
def create
authorize!(:update, @submitter)
if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter,
event_type: 'send_email',
created_at: 10.hours.ago..Time.current)
@@ -10,6 +10,8 @@ class TemplateDocumentsController < ApplicationController
end
def create
authorize!(:update, @template)
if params[:blobs].blank? && params[:files].blank?
return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_content
end
@@ -0,0 +1,39 @@
# frozen_string_literal: true
class TemplateDocumentsCropController < ApplicationController
load_and_authorize_resource :template
before_action :load_attachment
rescue_from Leptonica::LeptonicaError do
render json: { error: I18n.t(:unable_to_save) }, status: :unprocessable_content
end
def index
render json: { corners: Leptonica.detect_document_corners(@attachment.download) }
end
def create
authorize!(:update, @template)
document = Templates::CreateDocumentCrop.call(@template, @attachment, crop_params)
render json: {
document: document.as_json(
methods: %i[metadata signed_key],
include: {
preview_images: { methods: %i[url metadata filename] }
}
)
}
end
private
def load_attachment
@attachment = @template.documents_attachments.find_by!(uuid: params[:attachment_uuid])
end
def crop_params
params.permit(:scan, :rotate, :flip_h, :flip_v, corners: [%i[x y]])
end
end
@@ -0,0 +1,32 @@
# frozen_string_literal: true
class TemplateDocumentsModifyController < ApplicationController
load_and_authorize_resource :template
def create
authorize!(:update, @template)
documents_layout =
params.require(:documents).map do |item|
item.permit(:attachment_uuid,
pages: [:attachment_uuid, :page, :rotate,
{ redact: [%i[x y w h]], replaced_page: %i[attachment_uuid page] }]).to_h
end
Templates::ModifyDocuments.call(@template, documents_layout)
render json: {
schema: @template.schema,
fields: @template.fields,
submitters: @template.submitters,
documents: @template.schema_documents.reload.preload(:blob, preview_images_attachments: :blob).as_json(
methods: %i[metadata signed_key],
include: {
preview_images: { methods: %i[url metadata filename] }
}
)
}
rescue Templates::ModifyDocuments::InvalidLayout
render json: { error: I18n.t(:unable_to_save) }, status: :unprocessable_content
end
end
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class TemplateDocumentsPageObjectsController < ApplicationController
load_and_authorize_resource :template
def index
attachment = @template.documents_attachments.find_by!(uuid: params[:attachment_uuid])
render json: Templates::ModifyDocuments.page_objects(attachment, params[:page].to_i)
end
end
@@ -13,6 +13,9 @@ class TemplatesCloneAndReplaceController < ApplicationController
cloned_template = Templates::Clone.call(@template, author: current_user)
cloned_template.name = File.basename(params[:files].first.original_filename, '.*')
authorize!(:create, cloned_template)
cloned_template.save!
documents = Templates::ReplaceAttachments.call(cloned_template, params, extract_fields: true)
+1 -19
View File
@@ -1,10 +1,6 @@
# frozen_string_literal: true
class TemplatesController < ApplicationController
TEMPLATE_FIELDS = %i[id author_id folder_id external_id name slug
schema fields submitters variables_schema preferences
shared_link source archived_at created_at updated_at].freeze
load_and_authorize_resource :template
def show
@@ -31,19 +27,7 @@ class TemplatesController < ApplicationController
def new; end
def edit
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [{ schema_documents: [:blob, { preview_images_attachments: :blob }] }]
).call
@template_data =
@template.as_json(only: TEMPLATE_FIELDS).merge(
documents: @template.schema_documents.as_json(
only: %i[id uuid],
methods: %i[metadata signed_key],
include: { preview_images: { only: %i[id], methods: %i[url metadata filename] } }
)
).to_json
@template_data = Templates.serialize_for_builder(@template)
render :edit, layout: 'plain'
end
@@ -77,8 +61,6 @@ class TemplatesController < ApplicationController
WebhookUrls.enqueue_events(@template, 'template.updated')
TemplateVersions.find_or_create_for(@template, author: current_user) if params[:revision]
head :ok
end
@@ -16,7 +16,12 @@ class TemplatesDetectFieldsController < ApplicationController
page_number = params[:page].presence&.to_i
documents.each do |document|
io = StringIO.new(document.download)
io =
if document.image?
StringIO.new(document.preview_images.joins(:blob).find_by(blob: { filename: ['0.png', '0.jpg'] }).download)
else
StringIO.new(document.download)
end
Templates::DetectFields.call(io, attachment: document, page_number:) do |(attachment_uuid, page, fields)|
sse.write({ attachment_uuid:, page:, fields: })
@@ -6,6 +6,8 @@ class TemplatesFoldersController < ApplicationController
def edit; end
def update
authorize!(:update, @template)
name = [params[:parent_name], params[:name]].compact_blank.join(' / ')
@template.folder = TemplateFolders.find_or_create_by_name(current_user, name)
@@ -4,18 +4,7 @@ class TemplatesPreviewController < ApplicationController
load_and_authorize_resource :template
def show
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [{ schema_documents: { preview_images_attachments: :blob } }]
).call
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: %i[metadata signed_key],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
@template_data = Templates.serialize_for_builder(@template)
render :show, layout: 'plain'
end
@@ -4,6 +4,8 @@ class TemplatesRestoreController < ApplicationController
load_and_authorize_resource :template
def create
authorize!(:update, @template)
@template.update!(archived_at: nil)
WebhookUrls.enqueue_events(@template, 'template.updated')
@@ -5,7 +5,9 @@ class TemplatesUploadsController < ApplicationController
layout 'plain'
def show; end
def show
redirect_to root_path if params[:url].blank?
end
def create
url_params = create_file_params_from_url if params[:url].present?
@@ -14,4 +14,12 @@ class TemplatesVersionsController < ApplicationController
render json: TemplateVersions.serialize(version)
end
def create
authorize!(:update, @template)
TemplateVersions.find_or_create_for(@template, author: current_user)
head :ok
end
end
@@ -11,6 +11,12 @@ class UserInitialsController < ApplicationController
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_initials') if file.blank?
extension = File.extname(file.original_filename).delete_prefix('.').downcase
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
end
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
content_type: file.content_type)
@@ -11,6 +11,12 @@ class UserSignaturesController < ApplicationController
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_signature') if file.blank?
extension = File.extname(file.original_filename).delete_prefix('.').downcase
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
end
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
content_type: file.content_type)
+33 -6
View File
@@ -21,6 +21,7 @@ import SubmittersAutocomplete from './elements/submitter_autocomplete'
import FolderAutocomplete from './elements/folder_autocomplete'
import SignatureForm from './elements/signature_form'
import SubmitForm from './elements/submit_form'
import ConvertUpload from './elements/convert_upload'
import PromptPassword from './elements/prompt_password'
import EmailsTextarea from './elements/emails_textarea'
import ToggleSubmit from './elements/toggle_submit'
@@ -47,13 +48,13 @@ import ScrollTo from './elements/scroll_to'
import SetValue from './elements/set_value'
import ReviewForm from './elements/review_form'
import ShowOnValue from './elements/show_on_value'
import CustomValidation from './elements/custom_validation'
import ToggleClasses from './elements/toggle_classes'
import AutosizeField from './elements/autosize_field'
import GoogleDriveFilePicker from './elements/google_drive_file_picker'
import OpenModal from './elements/open_modal'
import BarChart from './elements/bar_chart'
import FieldCondition from './elements/field_condition'
import ConfirmUpload from './elements/confirm_upload'
import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -112,6 +113,7 @@ safeRegisterElement('submitters-autocomplete', SubmittersAutocomplete)
safeRegisterElement('folder-autocomplete', FolderAutocomplete)
safeRegisterElement('signature-form', SignatureForm)
safeRegisterElement('submit-form', SubmitForm)
safeRegisterElement('convert-upload', ConvertUpload)
safeRegisterElement('prompt-password', PromptPassword)
safeRegisterElement('emails-textarea', EmailsTextarea)
safeRegisterElement('toggle-cookies', ToggleCookies)
@@ -139,13 +141,13 @@ safeRegisterElement('scroll-to', ScrollTo)
safeRegisterElement('set-value', SetValue)
safeRegisterElement('review-form', ReviewForm)
safeRegisterElement('show-on-value', ShowOnValue)
safeRegisterElement('custom-validation', CustomValidation)
safeRegisterElement('toggle-classes', ToggleClasses)
safeRegisterElement('autosize-field', AutosizeField)
safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker)
safeRegisterElement('open-modal', OpenModal)
safeRegisterElement('bar-chart', BarChart)
safeRegisterElement('field-condition', FieldCondition)
safeRegisterElement('confirm-upload', ConfirmUpload)
safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () {
@@ -197,10 +199,35 @@ safeRegisterElement('template-builder', class extends HTMLElement {
}
onSubmit = (e) => {
if (e.detail.success && e.detail?.formSubmission?.formElement?.id === 'submitters_form') {
e.detail.fetchResponse.response.json().then((data) => {
this.component.template.submitters = data.submitters
})
if (e.detail.success) {
if (e.detail?.formSubmission?.formElement?.id === 'submitters_form') {
e.detail.fetchResponse.response.json().then((data) => {
this.component.template.submitters = data.submitters
})
}
if (e.detail?.formSubmission?.formElement?.action?.endsWith('/prefillable_fields')) {
e.detail.fetchResponse.response.text().then((data) => {
const doc = new DOMParser().parseFromString(data, 'text/html')
const fragment = doc.querySelector('turbo-stream template').content
const prefillableUuidsIndex = {}
fragment.querySelectorAll('[name="field_uuid"]').forEach((field) => {
prefillableUuidsIndex[field.value] = true
})
this.component.template.fields.forEach((field) => {
if (prefillableUuidsIndex[field.uuid]) {
field.prefillable = true
field.readonly = true
} else if (field.prefillable) {
delete field.prefillable
delete field.readonly
}
})
})
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import { target, targetable } from '@github/catalyst/lib/targetable'
export default targetable(class extends HTMLElement {
static [target.static] = [
'prompt',
'processing',
'logo'
]
connectedCallback () {
this.form.addEventListener('submit', this.onSubmit)
}
disconnectedCallback () {
this.form.removeEventListener('submit', this.onSubmit)
}
onSubmit = () => {
this.prompt.classList.add('hidden')
this.processing.classList.remove('hidden')
this.logo.classList.add('animate-bounce')
}
get form () {
return this.querySelector('form')
}
})
+73
View File
@@ -0,0 +1,73 @@
export function convertImage (sourceFile, targetType, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
resolve(newFile)
}, targetType, quality)
}
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(sourceFile)
})
}
export async function convertImagesInInput (input) {
if (!input.files || input.files.length === 0) return
const dt = new DataTransfer()
let didConvert = false
for (const file of Array.from(input.files)) {
let converted = file
try {
if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
converted = await convertImage(file, 'image/png')
didConvert = true
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
converted = await convertImage(file, 'image/jpeg', 0.9)
didConvert = true
}
} catch (e) {
alert(e.message)
}
dt.items.add(converted)
}
if (didConvert) {
input.files = dt.files
}
}
export default class extends HTMLElement {
connectedCallback () {
const input = this.querySelector('input[type="file"]')
const form = input.form
input.addEventListener('change', async () => {
await convertImagesInInput(input)
form.querySelector('[type="submit"]')?.setAttribute('disabled', true)
form.requestSubmit()
})
}
}
@@ -1,14 +0,0 @@
export default class extends HTMLElement {
connectedCallback () {
const input = this.querySelector('input')
const invalidMessage = this.dataset.invalidMessage || ''
input.addEventListener('invalid', () => {
input.setCustomValidity(input.value ? invalidMessage : '')
})
input.addEventListener('input', () => {
input.setCustomValidity('')
})
}
}
@@ -1,4 +1,5 @@
import { target, targets, targetable } from '@github/catalyst/lib/targetable'
import { convertImagesInInput } from './convert_upload'
const loadingIconHtml = `<svg xmlns="http://www.w3.org/2000/svg" class="animate-spin" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
@@ -150,12 +151,16 @@ export default targetable(class extends HTMLElement {
if (!this.isLoading) this.hideDraghover()
}
uploadFiles (files, url) {
async uploadFiles (files, url) {
this.isLoading = true
this.form.action = url
this.form.querySelector('[type="file"]').files = files
const input = this.form.querySelector('[type="file"]')
input.files = files
await convertImagesInInput(input)
this.form.querySelector('[type="submit"]').click()
}
+8 -3
View File
@@ -1,5 +1,6 @@
import { actionable } from '@github/catalyst/lib/actionable'
import { target, targetable } from '@github/catalyst/lib/targetable'
import { convertImagesInInput } from './convert_upload'
export default actionable(targetable(class extends HTMLElement {
static [target.static] = [
@@ -38,17 +39,21 @@ export default actionable(targetable(class extends HTMLElement {
this.classList.add('border-base-300', 'hover:bg-base-200/30')
}
onDrop (e) {
async onDrop (e) {
e.preventDefault()
this.input.files = e.dataTransfer.files
this.uploadFiles(e.dataTransfer.files)
await convertImagesInInput(this.input)
this.uploadFiles(this.input.files)
}
onSelectFiles (e) {
async onSelectFiles (e) {
e.preventDefault()
await convertImagesInInput(this.input)
this.uploadFiles(this.input.files)
}
+3 -1
View File
@@ -52,7 +52,9 @@ safeRegisterElement('submission-form', class extends HTMLElement {
completedMessage: JSON.parse(this.dataset.completedMessage || '{}'),
completedRedirectUrl: this.dataset.completedRedirectUrl,
attachments: reactive(JSON.parse(this.dataset.attachments)),
fields: JSON.parse(this.dataset.fields)
fields: JSON.parse(this.dataset.fields),
completeButtonContainer: document.getElementById('complete_button_container'),
completeButtonScrollContainer: document.getElementById('complete_button_container_scroll')
})
this.app.mount(this.appElem)
+7 -3
View File
@@ -528,7 +528,7 @@ export default {
return this.formatDate(
this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue),
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'),
{ withTimePlaceholders: this.modelValue === '{{date}}' }
{ withTimePlaceholders: this.modelValue === '{{date}}', utc: this.modelValue !== '{{date}}' }
)
} catch {
return this.modelValue
@@ -643,11 +643,15 @@ export default {
return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number)
} else if (format === 'percent') {
return `${number}%`
} else if (format === 'percent_space') {
return `${String(number).replace('.', ',')} %`
} else {
return number
}
},
formatDate (date, format, { withTimePlaceholders = false } = {}) {
formatDate (date, format, { withTimePlaceholders = false, utc = true } = {}) {
const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
const dayFormats = { D: 'numeric', DD: '2-digit' }
const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' }
@@ -669,7 +673,7 @@ export default {
if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]]
if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]]
if (/z/.test(format)) opts.timeZoneName = 'short'
if (!hasTime) opts.timeZone = 'UTC'
if (!hasTime && utc) opts.timeZone = 'UTC'
const partTypes = {
M: 'month',
@@ -27,6 +27,7 @@
</label>
<button
v-if="withToday"
type="button"
class="btn btn-outline btn-sm !normal-case font-normal set-current-date-button"
@click.prevent="[setCurrentDate(), $emit('focus')]"
>
+38 -28
View File
@@ -51,6 +51,36 @@
<script>
import { IconCloudUpload, IconInnerShadowTop } from '@tabler/icons-vue'
function convertImage (sourceFile, targetType, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
resolve(newFile)
}, targetType, quality)
}
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(sourceFile)
})
}
export default {
name: 'FileDropzone',
components: {
@@ -153,8 +183,14 @@ export default {
}
})
} else {
if (file.type === 'image/bmp' || file.type === 'image/vnd.microsoft.icon') {
file = await this.convertBmpToPng(file)
try {
if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
file = await convertImage(file, 'image/png')
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
file = await convertImage(file, 'image/jpeg', 0.9)
}
} catch (e) {
alert(e.message)
}
formData.append('file', file)
@@ -181,32 +217,6 @@ export default {
}).finally(() => {
this.isLoading = false
})
},
convertBmpToPng (bmpFile) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const newFile = new File([blob], bmpFile.name.replace(/\.\w+$/, '.png'), { type: 'image/png' })
resolve(newFile)
}, 'image/png')
}
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(bmpFile)
})
}
}
}
+52 -1
View File
@@ -95,7 +95,7 @@
v-else
id="complete_form_button"
class="btn btn-sm btn-neutral text-white px-4 w-full flex justify-center"
form="steps_form"
form="complete_form"
type="submit"
name="completed"
value="true"
@@ -113,6 +113,39 @@
</span>
</button>
</Teleport>
<Teleport
v-for="ref in (showCompleteButton ? [completeButtonContainer, completeButtonScrollContainer].filter(Boolean) : [])"
:key="ref"
:to="ref"
>
<button
class="complete-button btn btn-sm btn-neutral text-white px-4"
form="complete_form"
type="submit"
name="completed"
value="true"
:disabled="isSubmittingComplete"
>
<span class="flex items-center">
<IconInnerShadowTop
v-if="isSubmittingComplete"
class="mr-1 animate-spin w-5 h-5"
aria-hidden="true"
/>
<span>
{{ t('complete') }}
</span>
</span>
</button>
</Teleport>
<form
v-if="!isCompleted && !isInvite"
id="complete_form"
class="hidden"
:action="submitPath"
method="post"
@submit.prevent="submitStep"
/>
<button
v-if="!isFormVisible"
id="expand_form_button"
@@ -791,6 +824,16 @@ export default {
required: false,
default: null
},
completeButtonContainer: {
type: Object,
required: false,
default: null
},
completeButtonScrollContainer: {
type: Object,
required: false,
default: null
},
schema: {
type: Array,
required: false,
@@ -1008,6 +1051,7 @@ export default {
isSubmitting: false,
isSubmittingComplete: false,
submittedValues: {},
isFormStarted: false,
recalculateButtonDisabledKey: '',
isAccessibilityMode: false
}
@@ -1056,6 +1100,10 @@ export default {
})
})
},
showCompleteButton () {
return this.completeButtonContainer && !this.isCompleted && !this.isInvite && this.isFormStarted &&
!this.stepFields.find((fields) => fields.some((f) => f.required && isEmpty(this.submittedValues[f.uuid])))
},
submitButtonText () {
if (this.alwaysMinimize) {
return this.t('submit')
@@ -1657,6 +1705,8 @@ export default {
return Promise.reject(new Error(data.error))
}
this.isFormStarted = true
const nextStep = (isLastStep && emptyRequiredField) || (forceComplete ? null : this.findNextStep(submitStepIndex))
if (nextStep) {
@@ -1693,6 +1743,7 @@ export default {
},
async performComplete (resp) {
this.isCompleted = true
this.isFormVisible = true
if (resp?.text) {
const respData = await resp.text()
+98
View File
@@ -7,6 +7,13 @@ const en = {
kba: 'KBA',
please_upload_an_image_file: 'Please upload an image file',
must_be_characters_length: 'Must be {number} characters long',
must_be_valid_ssn: 'Must be a valid SSN (XXX-XX-XXXX)',
must_be_valid_ein: 'Must be a valid EIN (XX-XXXXXXX)',
must_be_valid_email: 'Must be a valid email address',
must_be_valid_url: 'Must be a valid URL (starting with http:// or https://)',
must_be_valid_zip: 'Must be a valid ZIP code (XXXXX or XXXXX-XXXX)',
must_contain_numbers_only: 'Must contain numbers only',
must_contain_letters_only: 'Must contain letters only',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.',
verify_id: 'Verify ID',
identity_verification: 'Identity verification',
@@ -116,6 +123,13 @@ const es = {
kba: 'KBA',
please_upload_an_image_file: 'Por favor, sube un archivo de imagen',
must_be_characters_length: 'Debe tener {number} caracteres de longitud',
must_be_valid_ssn: 'Debe ser un SSN válido (XXX-XX-XXXX)',
must_be_valid_ein: 'Debe ser un EIN válido (XX-XXXXXXX)',
must_be_valid_email: 'Debe ser una dirección de correo electrónico válida',
must_be_valid_url: 'Debe ser una URL válida (que comience con http:// o https://)',
must_be_valid_zip: 'Debe ser un ZIP válido (XXXXX o XXXXX-XXXX)',
must_contain_numbers_only: 'Debe contener solo números',
must_contain_letters_only: 'Debe contener solo letras',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.',
verify_id: 'Verificar ID',
identity_verification: 'Verificación de identidad',
@@ -225,6 +239,13 @@ const it = {
kba: 'KBA',
please_upload_an_image_file: 'Per favore carica un file immagine',
must_be_characters_length: 'Deve essere lungo {number} caratteri',
must_be_valid_ssn: 'Deve essere un SSN valido (XXX-XX-XXXX)',
must_be_valid_ein: 'Deve essere un EIN valido (XX-XXXXXXX)',
must_be_valid_email: 'Deve essere un indirizzo email valido',
must_be_valid_url: 'Deve essere un URL valido (che inizia con http:// o https://)',
must_be_valid_zip: 'Deve essere uno ZIP valido (XXXXX o XXXXX-XXXX)',
must_contain_numbers_only: 'Deve contenere solo numeri',
must_contain_letters_only: 'Deve contenere solo lettere',
complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.",
verify_id: 'Verifica ID',
identity_verification: "Verifica dell'identità",
@@ -334,6 +355,13 @@ const de = {
kba: 'KBA',
please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch',
must_be_characters_length: 'Muss {number} Zeichen lang sein',
must_be_valid_ssn: 'Muss eine gültige SSN sein (XXX-XX-XXXX)',
must_be_valid_ein: 'Muss eine gültige EIN sein (XX-XXXXXXX)',
must_be_valid_email: 'Muss eine gültige E-Mail-Adresse sein',
must_be_valid_url: 'Muss eine gültige URL sein (beginnend mit http:// oder https://)',
must_be_valid_zip: 'Muss eine gültige ZIP sein (XXXXX oder XXXXX-XXXX)',
must_contain_numbers_only: 'Darf nur Zahlen enthalten',
must_contain_letters_only: 'Darf nur Buchstaben enthalten',
complete_all_required_fields_to_proceed_with_identity_verification: 'Füllen Sie alle Pflichtfelder aus, um mit der Identitätsprüfung fortzufahren.',
verify_id: 'ID überprüfen',
identity_verification: 'Identitätsprüfung',
@@ -443,6 +471,13 @@ const fr = {
kba: 'KBA',
please_upload_an_image_file: 'Veuillez téléverser un fichier image',
must_be_characters_length: 'Doit comporter {number} caractères',
must_be_valid_ssn: 'Doit être un SSN valide (XXX-XX-XXXX)',
must_be_valid_ein: 'Doit être un EIN valide (XX-XXXXXXX)',
must_be_valid_email: 'Doit être une adresse e-mail valide',
must_be_valid_url: 'Doit être une URL valide (commençant par http:// ou https://)',
must_be_valid_zip: 'Doit être un ZIP valide (XXXXX ou XXXXX-XXXX)',
must_contain_numbers_only: 'Doit contenir uniquement des chiffres',
must_contain_letters_only: 'Doit contenir uniquement des lettres',
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour poursuivre la vérification d'identité.",
verify_id: "Vérifier l'ID",
identity_verification: "Vérification d'identité",
@@ -552,6 +587,13 @@ const pl = {
kba: 'KBA',
please_upload_an_image_file: 'Proszę przesłać plik obrazu',
must_be_characters_length: 'Musi mieć długość {number} znaków',
must_be_valid_ssn: 'Musi być prawidłowym numerem SSN (XXX-XX-XXXX)',
must_be_valid_ein: 'Musi być prawidłowym numerem EIN (XX-XXXXXXX)',
must_be_valid_email: 'Musi być prawidłowym adresem e-mail',
must_be_valid_url: 'Musi być prawidłowym adresem URL (zaczynającym się od http:// lub https://)',
must_be_valid_zip: 'Musi być prawidłowym ZIP (XXXXX lub XXXXX-XXXX)',
must_contain_numbers_only: 'Może zawierać tylko cyfry',
must_contain_letters_only: 'Może zawierać tylko litery',
complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.',
verify_id: 'Zweryfikuj ID',
identity_verification: 'Weryfikacja tożsamości',
@@ -661,6 +703,13 @@ const uk = {
kba: 'KBA',
please_upload_an_image_file: 'Будь ласка, завантажте файл зображення',
must_be_characters_length: 'Має містити {number} символів',
must_be_valid_ssn: 'Має бути дійсним SSN (XXX-XX-XXXX)',
must_be_valid_ein: 'Має бути дійсним EIN (XX-XXXXXXX)',
must_be_valid_email: 'Має бути дійсною адресою електронної пошти',
must_be_valid_url: 'Має бути дійсним URL (починаючи з http:// або https://)',
must_be_valid_zip: 'Має бути дійсним ZIP (XXXXX або XXXXX-XXXX)',
must_contain_numbers_only: 'Має містити лише цифри',
must_contain_letters_only: 'Має містити лише літери',
complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.",
verify_id: 'Підтвердження ідентичності',
identity_verification: 'Ідентифікація особи',
@@ -770,6 +819,13 @@ const cs = {
kba: 'KBA',
please_upload_an_image_file: 'Nahrajte prosím obrázkový soubor',
must_be_characters_length: 'Musí mít délku {number} znaků',
must_be_valid_ssn: 'Musí být platné SSN (XXX-XX-XXXX)',
must_be_valid_ein: 'Musí být platné EIN (XX-XXXXXXX)',
must_be_valid_email: 'Musí být platná e-mailová adresa',
must_be_valid_url: 'Musí být platná URL (začínající http:// nebo https://)',
must_be_valid_zip: 'Musí být platný ZIP (XXXXX nebo XXXXX-XXXX)',
must_contain_numbers_only: 'Musí obsahovat pouze čísla',
must_contain_letters_only: 'Musí obsahovat pouze písmena',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.',
verify_id: 'Ověřit ID',
identity_verification: 'Ověření identity',
@@ -879,6 +935,13 @@ const pt = {
kba: 'KBA',
please_upload_an_image_file: 'Por favor, envie um arquivo de imagem',
must_be_characters_length: 'Deve ter {number} caracteres',
must_be_valid_ssn: 'Deve ser um SSN válido (XXX-XX-XXXX)',
must_be_valid_ein: 'Deve ser um EIN válido (XX-XXXXXXX)',
must_be_valid_email: 'Deve ser um endereço de e-mail válido',
must_be_valid_url: 'Deve ser uma URL válida (começando com http:// ou https://)',
must_be_valid_zip: 'Deve ser um ZIP válido (XXXXX ou XXXXX-XXXX)',
must_contain_numbers_only: 'Deve conter apenas números',
must_contain_letters_only: 'Deve conter apenas letras',
complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.',
verify_id: 'Verificar ID',
identity_verification: 'Verificação de identidade',
@@ -988,6 +1051,13 @@ const he = {
kba: 'KBA',
please_upload_an_image_file: 'אנא העלה קובץ תמונה',
must_be_characters_length: 'חייב להיות באורך של {number} תווים',
must_be_valid_ssn: 'חייב להיות SSN חוקי (XXX-XX-XXXX)',
must_be_valid_ein: 'חייב להיות EIN חוקי (XX-XXXXXXX)',
must_be_valid_email: 'חייבת להיות כתובת אימייל חוקית',
must_be_valid_url: 'חייבת להיות כתובת URL חוקית (המתחילה ב-http:// או https://)',
must_be_valid_zip: 'חייב להיות ZIP חוקי (XXXXX או XXXXX-XXXX)',
must_contain_numbers_only: 'חייב להכיל מספרים בלבד',
must_contain_letters_only: 'חייב להכיל אותיות בלבד',
complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.',
verify_id: 'אמת מזהה',
identity_verification: 'אימות זהות',
@@ -1097,6 +1167,13 @@ const nl = {
kba: 'KBA',
please_upload_an_image_file: 'Upload alstublieft een afbeeldingsbestand',
must_be_characters_length: 'Moet {number} tekens lang zijn',
must_be_valid_ssn: 'Moet een geldig SSN zijn (XXX-XX-XXXX)',
must_be_valid_ein: 'Moet een geldig EIN zijn (XX-XXXXXXX)',
must_be_valid_email: 'Moet een geldig e-mailadres zijn',
must_be_valid_url: 'Moet een geldige URL zijn (beginnend met http:// of https://)',
must_be_valid_zip: 'Moet een geldige ZIP zijn (XXXXX of XXXXX-XXXX)',
must_contain_numbers_only: 'Mag alleen cijfers bevatten',
must_contain_letters_only: 'Mag alleen letters bevatten',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.',
verify_id: 'ID verifiëren',
identity_verification: 'Identiteitsverificatie',
@@ -1206,6 +1283,13 @@ const ar = {
kba: 'KBA',
please_upload_an_image_file: 'يرجى تحميل ملف صورة',
must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا',
must_be_valid_ssn: 'يجب أن يكون SSN صالحًا (XXX-XX-XXXX)',
must_be_valid_ein: 'يجب أن يكون EIN صالحًا (XX-XXXXXXX)',
must_be_valid_email: 'يجب أن يكون عنوان بريد إلكتروني صالحًا',
must_be_valid_url: 'يجب أن يكون عنوان URL صالحًا (يبدأ بـ http:// أو https://)',
must_be_valid_zip: 'يجب أن يكون ZIP صالحًا (XXXXX أو XXXXX-XXXX)',
must_contain_numbers_only: 'يجب أن يحتوي على أرقام فقط',
must_contain_letters_only: 'يجب أن يحتوي على أحرف فقط',
complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.',
verify_id: 'تحقق من الهوية',
identity_verification: 'التحقق من الهوية',
@@ -1315,6 +1399,13 @@ const ko = {
kba: 'KBA',
please_upload_an_image_file: '이미지 파일을 업로드해 주세요',
must_be_characters_length: '{number}자여야 합니다',
must_be_valid_ssn: '유효한 SSN이어야 합니다 (XXX-XX-XXXX)',
must_be_valid_ein: '유효한 EIN이어야 합니다 (XX-XXXXXXX)',
must_be_valid_email: '유효한 이메일 주소여야 합니다',
must_be_valid_url: '유효한 URL이어야 합니다 (http:// 또는 https://로 시작)',
must_be_valid_zip: '유효한 ZIP이어야 합니다 (XXXXX 또는 XXXXX-XXXX)',
must_contain_numbers_only: '숫자만 포함해야 합니다',
must_contain_letters_only: '문자만 포함해야 합니다',
complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.',
verify_id: '아이디 확인',
identity_verification: '신원 확인',
@@ -1424,6 +1515,13 @@ const ja = {
kba: 'KBA',
please_upload_an_image_file: '画像ファイルをアップロードしてください',
must_be_characters_length: '{number}文字でなければなりません',
must_be_valid_ssn: '有効なSSNである必要があります (XXX-XX-XXXX)',
must_be_valid_ein: '有効なEINである必要があります (XX-XXXXXXX)',
must_be_valid_email: '有効なメールアドレスである必要があります',
must_be_valid_url: '有効なURLである必要があります (http://またはhttps://で始まる)',
must_be_valid_zip: '有効なZIPである必要があります (XXXXX または XXXXX-XXXX)',
must_contain_numbers_only: '数字のみを含む必要があります',
must_contain_letters_only: '文字のみを含む必要があります',
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
verify_id: '本人確認',
identity_verification: '本人確認',
@@ -15,6 +15,7 @@
</template>
</label>
<button
type="button"
class="btn btn-outline btn-sm reupload-button"
@click.prevent="remove"
>
@@ -42,6 +42,7 @@
{{ error }}
</div>
<button
type="button"
class="base-button w-full flex justify-center submit-form-button"
@click="restartKba"
>
@@ -33,6 +33,7 @@
<div v-else>
<button
v-if="sessionId"
type="button"
disabled
class="base-button w-full modal-save-button"
>
@@ -47,6 +48,7 @@
<button
v-else
:id="field.uuid"
type="button"
class="btn bg-[#7B73FF] text-white hover:bg-[#0A2540] text-lg w-full"
:class="{ disabled: isCreatingCheckout }"
:disabled="isCreatingCheckout"
+16 -2
View File
@@ -41,12 +41,11 @@
:class="{ '!pr-11 -mr-10': !field.validation?.pattern }"
:required="field.required"
:pattern="field.validation?.pattern"
:title="validationMessage"
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
type="text"
:name="`values[${field.uuid}]`"
@invalid="validationMessage ? $event.target.setCustomValidity(validationMessage) : ''"
@input="validationMessage ? $event.target.setCustomValidity('') : ''"
@focus="$emit('focus')"
>
<textarea
@@ -138,6 +137,17 @@ export default {
return null
}
},
patternMessageKeys () {
return {
'^[0-9]{3}-[0-9]{2}-[0-9]{4}$': 'must_be_valid_ssn',
'^[0-9]{2}-[0-9]{7}$': 'must_be_valid_ein',
'^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'must_be_valid_email',
'^https?://.*': 'must_be_valid_url',
'^[0-9]{5}(?:-[0-9]{4})?$': 'must_be_valid_zip',
'^[0-9]+$': 'must_contain_numbers_only',
'^[a-zA-Z]+$': 'must_contain_letters_only'
}
},
validationMessage () {
if (this.field.validation?.message) {
return this.field.validation.message
@@ -149,6 +159,10 @@ export default {
.join('-')
return this.t('must_be_characters_length').replace('{number}', number)
} else if (this.field.validation?.pattern) {
const key = this.patternMessageKeys[this.field.validation.pattern]
return key ? this.t(key) : ''
} else {
return ''
}
+20 -21
View File
@@ -197,7 +197,8 @@
v-else-if="!isCheckboxInput"
width="100%"
height="100%"
class="max-h-10 opacity-50"
class="opacity-50"
:style="{ maxHeight: isInlineSize ? '4.4cqmin' : '40px' }"
/>
</span>
</div>
@@ -620,6 +621,10 @@ export default {
return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number)
} else if (format === 'percent') {
return `${number}%`
} else if (format === 'percent_space') {
return `${String(number).replace('.', ',')} %`
} else {
return number
}
@@ -716,14 +721,6 @@ export default {
}
}
},
drag (e) {
if (e.target.id === 'mask' && this.editable) {
this.isDragged = true
this.area.x = (e.offsetX - this.dragFrom.x) / e.target.clientWidth
this.area.y = (e.offsetY - this.dragFrom.y) / e.target.clientHeight
}
},
startTouchDrag (e) {
if (e.target !== this.$refs.touchTarget && e.target !== this.$refs.touchValueTarget) {
return
@@ -769,6 +766,8 @@ export default {
this.maybeChangeAreaPage(this.area)
this.clampAreaBounds(this.area)
if (this.isDragged) {
this.save()
}
@@ -836,6 +835,8 @@ export default {
this.maybeChangeAreaPage(this.area)
this.clampAreaBounds(this.area)
if (this.isMoved) {
this.save()
}
@@ -860,18 +861,6 @@ export default {
area.y = area.y - 1 - (16.0 / this.$parent.$refs.mask.previousSibling.offsetHeight)
}
},
stopDrag () {
this.$el.getRootNode().removeEventListener('mousemove', this.drag)
this.$el.getRootNode().removeEventListener('mouseup', this.stopDrag)
if (this.isDragged) {
this.save()
}
this.isDragged = false
this.$emit('stop-drag')
},
startResize () {
if (!this.selectedAreasRef.value.includes(this.area)) {
this.selectedAreasRef.value = [this.area]
@@ -886,6 +875,8 @@ export default {
this.$el.getRootNode().removeEventListener('mousemove', this.resize)
this.$el.getRootNode().removeEventListener('mouseup', this.stopResize)
this.clampAreaBounds(this.area)
this.$emit('stop-resize')
this.save()
@@ -924,9 +915,17 @@ export default {
this.$el.getRootNode().removeEventListener('touchmove', this.touchResize)
this.$el.getRootNode().removeEventListener('touchend', this.stopTouchResize)
this.clampAreaBounds(this.area)
this.$emit('stop-resize')
this.save()
},
clampAreaBounds (area) {
area.x = Math.min(Math.max(area.x, 0), 1)
area.y = Math.min(Math.max(area.y, 0), 1)
area.w = Math.min(Math.max(area.w, 0), 1)
area.h = Math.min(Math.max(area.h, 0), 1)
}
}
}
+125 -10
View File
@@ -309,6 +309,8 @@
:data-document-uuid="item.attachment_uuid"
:accept-file-types="acceptFileTypes"
:with-replace-button="withUploadButton"
:with-google-drive="withGoogleDrive"
:authenticity-token="authenticityToken"
:editable="editable"
:dynamic-documents="dynamicDocuments"
:with-dynamic-documents="withDynamicDocuments"
@@ -318,6 +320,7 @@
@replace="onDocumentReplace"
@up="moveDocument(item, -1)"
@reorder="reorderFields"
@edit="editModalDocumentUuid = item.attachment_uuid"
@down="moveDocument(item, 1)"
@change="save"
/>
@@ -355,12 +358,18 @@
</div>
<div
id="pages_container"
class="w-full overflow-x-hidden mt-0.5 pt-0.5"
:class="isMobile ? 'overflow-y-auto' : 'overflow-y-hidden md:overflow-y-auto'"
ref="pagesContainer"
class="w-full mt-0.5 pt-0.5"
:class="[
isMobile ? 'overflow-y-auto' : 'overflow-y-hidden md:overflow-y-auto',
zoomLevel > 1 ? 'overflow-x-auto' : 'overflow-x-hidden'
]"
@wheel="onPagesWheel"
>
<div
ref="documents"
class="pr-3.5 pl-0.5"
:style="zoomLevel > 1 ? { width: `${zoomLevel * 100}%` } : null"
>
<template v-if="!sortedDocuments.length && (withUploadButton || withAddPageButton)">
<Dropzone
@@ -451,6 +460,8 @@
:with-arrows="template.schema.length > 1"
:item="template.schema.find((item) => item.attachment_uuid === document.uuid)"
:with-replace-button="withUploadButton"
:with-google-drive="withGoogleDrive"
:authenticity-token="authenticityToken"
:accept-file-types="acceptFileTypes"
:document="document"
:template="template"
@@ -633,6 +644,36 @@
</div>
</div>
</Transition>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="translate-y-4 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-4 opacity-0"
>
<div
v-if="zoomLevel > 1"
class="sticky bottom-0 z-40 pointer-events-none"
>
<div class="absolute left-0 right-0 bottom-4 flex justify-center">
<div class="join shadow pointer-events-auto">
<span class="join-item bg-base-content text-white pl-2 pr-2.5 h-9 items-center text-sm font-medium cursor-default w-16 flex justify-end">
<span>
{{ Math.round(zoomLevel * 100) }}%
</span>
</span>
<button
type="button"
class="join-item bg-base-content text-white h-9 pl-2 pr-3 inline-flex items-center justify-center cursor-pointer hover:opacity-90 border-l border-white/20"
@click="zoomLevel = 1"
>
<IconX class="w-4 h-4 stroke-2" />
</button>
</div>
</div>
</div>
</Transition>
<div
id="docuseal_modal_container"
class="modal-container"
@@ -645,6 +686,17 @@
@close="isRevisionsModalOpen = false"
@apply="onRevisionApply"
/>
<DocumentsEditorModal
v-if="editModalDocumentUuid"
:template="template"
:authenticity-token="authenticityToken"
:accept-file-types="acceptFileTypes"
:base-url="baseUrl"
:page-preview-format="pagePreviewFormat"
:scroll-to-attachment-uuid="editModalDocumentUuid"
@saved="onDocumentsModified"
@close="editModalDocumentUuid = null"
/>
</div>
</div>
</template>
@@ -664,7 +716,8 @@ import DocumentControls from './controls'
import MobileFields from './mobile_fields'
import FieldSubmitter from './field_submitter'
import RevisionsModal from './revisions_modal'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload, IconHistory } from '@tabler/icons-vue'
import DocumentsEditorModal from './documents_editor_modal'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload, IconHistory, IconX } from '@tabler/icons-vue'
import { v4 } from 'uuid'
import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
import * as i18n from './i18n'
@@ -706,7 +759,9 @@ export default {
IconEye,
IconHistory,
IconDeviceFloppy,
RevisionsModal
IconX,
RevisionsModal,
DocumentsEditorModal
},
provide () {
return {
@@ -1062,8 +1117,10 @@ export default {
isDragFile: false,
isMathLoaded: false,
isRevisionsModalOpen: false,
editModalDocumentUuid: null,
revisions: [],
beforeRevisionSnapshot: null
beforeRevisionSnapshot: null,
zoomLevel: 1
}
},
computed: {
@@ -1636,7 +1693,7 @@ export default {
ref.x = e.clientX - ref.offsetX
ref.y = e.clientY - ref.offsetY
} else if (e.dataTransfer?.types?.includes('Files')) {
} else if (e.dataTransfer?.types?.includes('Files') && !this.editModalDocumentUuid) {
this.isDragFile = true
}
},
@@ -2018,6 +2075,30 @@ export default {
this.isBreakpointLg = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth < breakpointLg
},
onPagesWheel (event) {
if (!event.ctrlKey && !event.metaKey) return
event.preventDefault()
const oldZoom = this.zoomLevel
const nextZoom = Math.max(1, Math.min(3, oldZoom - event.deltaY * 0.006))
if (nextZoom === oldZoom) return
const rect = this.$refs.pagesContainer.getBoundingClientRect()
const cursorX = event.clientX - rect.left
const cursorY = event.clientY - rect.top
const ratio = nextZoom / oldZoom
const nextScrollLeft = (this.$refs.pagesContainer.scrollLeft + cursorX) * ratio - cursorX
const nextScrollTop = (this.$refs.pagesContainer.scrollTop + cursorY) * ratio - cursorY
this.zoomLevel = nextZoom
this.$nextTick(() => {
this.$refs.pagesContainer.scrollLeft = nextScrollLeft
this.$refs.pagesContainer.scrollTop = nextScrollTop
})
},
setDocumentRefs (el) {
if (el) {
this.documentRefs.push(el)
@@ -2055,6 +2136,10 @@ export default {
}
},
onKeyDown (event) {
if (this.editModalDocumentUuid) {
return
}
if (event.key === 'Tab' && document.activeElement === document.body) {
event.stopImmediatePropagation()
event.preventDefault()
@@ -2512,6 +2597,11 @@ export default {
this.selectedAreasRef.value = [area]
area.x = Math.min(Math.max(area.x, 0), 1)
area.y = Math.min(Math.max(area.y, 0), 1)
area.w = Math.min(Math.max(area.w, 0), 1)
area.h = Math.min(Math.max(area.h, 0), 1)
this.save()
} else {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
@@ -3068,6 +3158,22 @@ export default {
onDocumentsReplaceAndTemplateClone (template) {
window.Turbo.visit(`/templates/${template.id}/edit`)
},
onDocumentsModified (data) {
this.template.schema = data.schema
this.template.fields = data.fields
this.template.submitters = data.submitters
this.template.documents = data.documents
this.selectedAreasRef.value = []
if (!this.template.submitters.find((s) => s.uuid === this.selectedSubmitter?.uuid)) {
this.selectedSubmitter = this.template.submitters[0]
}
this.editModalDocumentUuid = null
this.save()
},
moveDocument (item, direction) {
const currentIndex = this.template.schema.indexOf(item)
@@ -3141,7 +3247,11 @@ export default {
const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody())
Promise.all([this.save({ force: true, revision: this.withRevisions }), ...dynamicDocumentSaves]).then(() => {
Promise.all([this.save({ force: true }), ...dynamicDocumentSaves]).then(() => {
if (this.withRevisions) {
this.captureRevision()
}
window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => {
this.isSaving = false
@@ -3372,7 +3482,7 @@ export default {
}
})
},
save ({ force = false, revision = false } = {}) {
save ({ force = false } = {}) {
this.pendingFieldAttachmentUuids = []
if (this.beforeRevisionSnapshot) {
@@ -3404,8 +3514,7 @@ export default {
submitters: this.template.submitters,
fields: this.template.fields,
variables_schema: this.template.variables_schema
},
...(revision ? { revision: true } : {})
}
}),
headers: { 'Content-Type': 'application/json' }
}).then(() => {
@@ -3414,6 +3523,12 @@ export default {
}
})
},
captureRevision () {
return this.baseFetch(`/templates/${this.template.id}/versions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
},
onDynamicDocumentUpdate () {
this.rebuildVariablesSchema()
@@ -10,6 +10,9 @@
v-if="withReplaceButton"
:template-id="template.id"
:accept-file-types="acceptFileTypes"
:authenticity-token="authenticityToken"
:with-google-drive="withGoogleDrive"
:google-drive-file-id="item.google_drive_file_id"
@click.stop
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
/>
@@ -73,6 +76,16 @@ export default {
required: true,
default: true
},
withGoogleDrive: {
type: Boolean,
required: false,
default: false
},
authenticityToken: {
type: String,
required: false,
default: ''
},
withArrows: {
type: Boolean,
required: false,
@@ -0,0 +1,328 @@
<template>
<div class="flex flex-1 min-h-0">
<div
class="flex-1 min-h-0 flex items-center justify-center px-6 py-4"
style="container-type: size"
>
<div
ref="pageEl"
class="relative select-none"
:style="pageStyle"
>
<img
:src="imageUrl"
:width="metadata.width"
:height="metadata.height"
class="absolute border rounded pointer-events-none"
style="left: 50%; top: 50%; width: 100%; height: auto"
:style="imageStyle"
>
<svg
class="absolute inset-0 w-full h-full pointer-events-none"
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
<path
:d="dimPath"
fill="black"
fill-opacity="0.4"
fill-rule="evenodd"
/>
<polygon
:points="polygonPoints"
fill="none"
stroke="white"
stroke-width="0.4"
vector-effect="non-scaling-stroke"
/>
</svg>
<div
v-for="(corner, cornerIndex) in displayCorners"
:key="cornerIndex"
class="absolute w-5 h-5 -ml-2.5 -mt-2.5 rounded-full bg-white border-2 border-neutral-600 cursor-move shadow"
:style="{ left: `${corner.x * 100}%`, top: `${corner.y * 100}%` }"
@mousedown.prevent="onCornerMousedown(cornerIndex)"
/>
</div>
</div>
<div class="w-56 flex-none border-l px-4 py-4 space-y-2 flex flex-col">
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
:disabled="!!isProcessing"
@click.prevent="submit(true)"
>
<IconInnerShadowTop
v-if="isProcessing === 'scan'"
class="w-4 h-4 animate-spin"
/>
<IconScan
v-else
class="w-4 h-4"
/>
{{ t('crop_and_scan') }}
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
:disabled="!!isProcessing"
@click.prevent="submit(false)"
>
<IconInnerShadowTop
v-if="isProcessing === 'crop'"
class="w-4 h-4 animate-spin"
/>
<IconCrop
v-else
width="22"
height="22"
style="margin-left: -3px"
:stroke-width="1.5"
/>
<span :style="{ 'margin-left': isProcessing === 'crop' ? '0px' : '-3px' }">
{{ t('crop') }}
</span>
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
@click.prevent="$emit('cancel')"
>
<IconX class="w-4 h-4" />
{{ t('cancel') }}
</button>
<div class="border-t !mt-3 !mb-1" />
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
@click.prevent="rotateCw"
>
<IconRotateClockwise class="w-4 h-4" />
{{ t('rotate') }}
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
:class="{ 'btn-active': flipH }"
@click.prevent="toggleFlip('flipH')"
>
<IconFlipVertical class="w-4 h-4" />
{{ t('flip_horizontal') }}
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
:class="{ 'btn-active': flipV }"
@click.prevent="toggleFlip('flipV')"
>
<IconFlipHorizontal class="w-4 h-4" />
{{ t('flip_vertical') }}
</button>
</div>
</div>
</template>
<script>
import { IconCrop, IconScan, IconInnerShadowTop, IconX, IconRotateClockwise, IconFlipHorizontal, IconFlipVertical } from '@tabler/icons-vue'
export default {
name: 'DocumentsEditorCrop',
components: {
IconCrop,
IconScan,
IconInnerShadowTop,
IconX,
IconRotateClockwise,
IconFlipHorizontal,
IconFlipVertical
},
inject: ['t', 'baseFetch', 'isInlineSize'],
props: {
templateId: {
type: [Number, String],
required: true
},
page: {
type: Object,
required: true
},
imageUrl: {
type: String,
required: true
},
metadata: {
type: Object,
required: true
}
},
emits: ['apply', 'cancel'],
data () {
return {
corners: [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
{ x: 0, y: 1 }
],
cornersTouched: false,
rotate: this.page.rotate || 0,
flipH: false,
flipV: false,
draggingIndex: null,
isProcessing: null
}
},
computed: {
displayWidth () {
return this.rotate % 180 ? this.metadata.height : this.metadata.width
},
displayHeight () {
return this.rotate % 180 ? this.metadata.width : this.metadata.height
},
pageStyle () {
const ratio = this.displayWidth / this.displayHeight
return {
aspectRatio: `${this.displayWidth} / ${this.displayHeight}`,
width: this.isInlineSize ? `min(100cqw, calc(100cqh * ${ratio}))` : `min(100%, calc(78vh * ${ratio}))`
}
},
imageStyle () {
const scale = this.rotate % 180 ? this.metadata.width / this.metadata.height : 1
const scaleX = (this.flipH ? -1 : 1) * scale
const scaleY = (this.flipV ? -1 : 1) * scale
return {
transform: `translate(-50%, -50%) rotate(${this.rotate}deg) scale(${scaleX}, ${scaleY})`
}
},
displayCorners () {
return this.corners.map((corner) => this.transformPoint(corner, this.rotate, this.flipH, this.flipV))
},
polygonPoints () {
return this.displayCorners.map((corner) => `${corner.x * 100},${corner.y * 100}`).join(' ')
},
dimPath () {
const quad = this.displayCorners.map((corner) => `${corner.x * 100} ${corner.y * 100}`)
return `M 0 0 H 100 V 100 H 0 Z M ${quad.join(' L ')} Z`
}
},
created () {
const query = new URLSearchParams({ attachment_uuid: this.page.sourceUuid })
this.baseFetch(`/templates/${this.templateId}/documents_crop?${query}`).then(async (resp) => {
if (resp.ok) {
const data = await resp.json()
if (data.corners?.length === 4 && !this.cornersTouched) {
this.corners = data.corners.map((corner) => ({ x: corner.x, y: corner.y }))
}
}
})
},
beforeUnmount () {
window.removeEventListener('mousemove', this.onMousemove)
window.removeEventListener('mouseup', this.onMouseup)
},
methods: {
transformPoint (point, rotate, flipH, flipV) {
let { x, y } = point
if (flipH) {
x = 1 - x
}
if (flipV) {
y = 1 - y
}
if (rotate === 90) {
return { x: 1 - y, y: x }
} else if (rotate === 180) {
return { x: 1 - x, y: 1 - y }
} else if (rotate === 270) {
return { x: y, y: 1 - x }
} else {
return { x, y }
}
},
inverseTransformPoint (point, rotate, flipH, flipV) {
let { x, y } = point
if (rotate === 90) {
[x, y] = [y, 1 - x]
} else if (rotate === 180) {
[x, y] = [1 - x, 1 - y]
} else if (rotate === 270) {
[x, y] = [1 - y, x]
}
if (flipH) {
x = 1 - x
}
if (flipV) {
y = 1 - y
}
return { x, y }
},
rotateCw () {
this.rotate = (this.rotate + 90) % 360
},
toggleFlip (key) {
this[key] = !this[key]
},
pagePoint (event) {
const rect = this.$refs.pageEl.getBoundingClientRect()
return {
x: Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1),
y: Math.min(Math.max((event.clientY - rect.top) / rect.height, 0), 1)
}
},
onCornerMousedown (index) {
this.draggingIndex = index
this.cornersTouched = true
window.addEventListener('mousemove', this.onMousemove)
window.addEventListener('mouseup', this.onMouseup, { once: true })
},
onMousemove (event) {
if (this.draggingIndex === null) {
return
}
const point = this.inverseTransformPoint(this.pagePoint(event), this.rotate, this.flipH, this.flipV)
this.corners[this.draggingIndex] = point
},
onMouseup () {
window.removeEventListener('mousemove', this.onMousemove)
this.draggingIndex = null
},
submit (scan) {
this.isProcessing = scan ? 'scan' : 'crop'
this.baseFetch(`/templates/${this.templateId}/documents_crop`, {
method: 'POST',
body: JSON.stringify({
attachment_uuid: this.page.sourceUuid,
corners: this.corners,
rotate: this.rotate || undefined,
flip_h: this.flipH,
flip_v: this.flipV,
scan
}),
headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => {
const data = await resp.json().catch(() => ({}))
if (resp.ok) {
this.$emit('apply', data.document)
} else if (data.error) {
alert(data.error)
}
}).finally(() => {
this.isProcessing = null
})
}
}
}
</script>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,224 @@
<template>
<div class="relative">
<div
class="relative"
:style="boxStyle"
>
<img
:src="imageUrl"
:width="metadata.width"
:height="metadata.height"
class="rounded border pointer-events-none outline outline-1 -outline-offset-1 transition-[outline-color] duration-75"
:class="[
page.rotate % 180 ? 'absolute inset-0 m-auto w-full' : 'w-full',
selected ? 'outline-neutral-400' : 'outline-transparent'
]"
:style="imageStyle"
:loading="lazy ? 'lazy' : 'eager'"
>
<div
v-if="areas.length || page.redact.length"
class="absolute pointer-events-none"
:style="overlayStyle"
>
<div
v-for="(item, areaIndex) in areas"
:key="areaIndex"
class="absolute border rounded-sm opacity-70"
:class="[areaBorderColor(item.submitterIndex), areaBgColor(item.submitterIndex)]"
:style="{
left: `${item.area.x * 100}%`,
top: `${item.area.y * 100}%`,
width: `${item.area.w * 100}%`,
height: `${item.area.h * 100}%`
}"
/>
<div
v-for="(rect, rectIndex) in page.redact"
:key="`redact-${rectIndex}`"
class="absolute bg-black"
:style="{
left: `${rect.x * 100}%`,
top: `${rect.y * 100}%`,
width: `${rect.w * 100}%`,
height: `${rect.h * 100}%`
}"
/>
</div>
<div
v-if="withActions"
class="absolute top-1 right-1 flex space-x-1 group-hover:opacity-100"
:class="selected ? 'opacity-100' : 'opacity-0'"
>
<span
v-if="extraAction"
class="tooltip tooltip-bottom"
:data-tip="t(extraAction)"
>
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content transition-colors p-0"
style="width: 22px; height: 22px; min-height: 22px"
@click.stop.prevent="$emit(extraAction)"
>
<IconEraser
v-if="extraAction === 'redact'"
:width="14"
:height="14"
:stroke-width="1.6"
/>
<IconCrop
v-else
:width="20"
:height="20"
:stroke-width="1.2"
/>
</button>
</span>
<span
class="tooltip tooltip-bottom"
:data-tip="t('rotate')"
>
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content transition-colors p-0"
style="width: 22px; height: 22px; min-height: 22px"
@click.stop.prevent="$emit('rotate')"
>
<IconRotateClockwise
:width="14"
:height="14"
:stroke-width="1.6"
/>
</button>
</span>
<span
class="tooltip tooltip-bottom"
:data-tip="t('remove')"
>
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content transition-colors p-0"
style="width: 22px; height: 22px; min-height: 22px"
@click.stop.prevent="$emit('remove')"
>
<IconX
:width="14"
:height="14"
:stroke-width="1.6"
/>
</button>
</span>
</div>
</div>
<div
v-if="pageNumber"
class="text-center text-sm pt-1 pointer-events-none"
>
{{ t('page') }} {{ pageNumber }}
</div>
</div>
</template>
<script>
import { IconRotateClockwise, IconX, IconEraser, IconCrop } from '@tabler/icons-vue'
import Area from './area.vue'
export default {
name: 'DocumentsEditorPage',
components: {
IconRotateClockwise,
IconX,
IconEraser,
IconCrop
},
inject: ['t'],
props: {
page: {
type: Object,
required: true
},
imageUrl: {
type: String,
required: true
},
metadata: {
type: Object,
required: true
},
areas: {
type: Array,
required: false,
default: () => []
},
selected: {
type: Boolean,
required: false,
default: false
},
withActions: {
type: Boolean,
required: false,
default: false
},
pageNumber: {
type: Number,
required: false,
default: null
},
lazy: {
type: Boolean,
required: false,
default: true
},
extraAction: {
type: String,
required: false,
default: null
}
},
emits: ['rotate', 'remove', 'redact', 'crop'],
computed: {
borderColors: Area.computed.borderColors,
bgColors: Area.computed.bgColors,
boxStyle () {
if (!this.page.rotate || !(this.page.rotate % 180)) {
return null
}
return { aspectRatio: `${this.metadata.height} / ${this.metadata.width}` }
},
imageStyle () {
if (!this.page.rotate) {
return null
}
let transform = `rotate(${this.page.rotate}deg)`
if (this.page.rotate % 180) {
transform += ` scale(${this.metadata.width / this.metadata.height})`
}
return { transform }
},
overlayStyle () {
if (!this.page.rotate || !(this.page.rotate % 180)) {
return { inset: '0', transform: this.page.rotate ? `rotate(${this.page.rotate}deg)` : undefined }
}
return {
left: '50%',
top: '50%',
width: '100%',
aspectRatio: `${this.metadata.width} / ${this.metadata.height}`,
transform: `translate(-50%, -50%) rotate(${this.page.rotate}deg) scale(${this.metadata.width / this.metadata.height})`
}
}
},
methods: {
areaBorderColor (submitterIndex) {
return this.borderColors[Math.max(submitterIndex, 0) % this.borderColors.length]
},
areaBgColor (submitterIndex) {
return this.bgColors[Math.max(submitterIndex, 0) % this.bgColors.length]
}
}
}
</script>
@@ -0,0 +1,413 @@
<template>
<div class="flex flex-1 min-h-0">
<div class="flex-1 overflow-y-auto px-6 py-4">
<div
ref="pageEl"
class="relative mx-auto select-none cursor-crosshair"
:style="pageStyle"
@mousedown.prevent="onMousedown"
>
<img
:src="imageUrl"
:width="metadata.width"
:height="metadata.height"
class="absolute border rounded pointer-events-none"
style="left: 50%; top: 50%; width: 100%; height: auto"
:style="imageStyle"
>
<div
class="absolute pointer-events-none"
:style="overlayStyle"
>
<div
v-for="(rect, rectIndex) in redactRects"
:key="`rect-${rectIndex}`"
class="absolute bg-black pointer-events-none"
:style="{
left: `${rect.x * 100}%`,
top: `${rect.y * 100}%`,
width: `${rect.w * 100}%`,
height: `${rect.h * 100}%`
}"
/>
</div>
<div
v-if="marquee"
class="absolute border border-neutral-600 bg-neutral-600/10 pointer-events-none"
:style="marqueeStyle"
/>
<div
v-if="!imagePage && textNodes && !textNodes.length && !imageNodes.length"
class="absolute inset-x-0 top-0 flex justify-center pt-4 pointer-events-none"
>
<span class="bg-base-100/90 border border-neutral-200 rounded-lg shadow px-4 py-2 text-sm">
{{ t('there_is_no_text_to_redact_on_this_page') }}
</span>
</div>
</div>
</div>
<div class="w-56 flex-none border-l px-4 py-4 space-y-2">
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
:disabled="!hasRedactions && !wasReset"
@click.prevent="apply"
>
<IconCheck class="w-4 h-4" />
{{ t('apply') }}
</button>
<div class="border-t !mt-3 !mb-1" />
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
:disabled="!hasRedactions"
@click.prevent="reset"
>
<IconRotate class="w-4 h-4" />
{{ t('reset') }}
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
@click.prevent="$emit('cancel')"
>
<IconX class="w-4 h-4" />
{{ t('cancel') }}
</button>
</div>
</div>
</template>
<script>
import { IconCheck, IconRotate, IconX } from '@tabler/icons-vue'
export default {
name: 'DocumentsEditorRedact',
components: {
IconCheck,
IconRotate,
IconX
},
inject: ['t', 'baseFetch'],
props: {
templateId: {
type: [Number, String],
required: true
},
page: {
type: Object,
required: true
},
imageUrl: {
type: String,
required: true
},
metadata: {
type: Object,
required: true
},
imagePage: {
type: Boolean,
required: false,
default: false
},
pageObjectsCache: {
type: Object,
required: false,
default: () => ({})
}
},
emits: ['apply', 'cancel'],
data () {
return {
textNodes: null,
imageNodes: [],
selectedNodes: {},
freeRects: [],
rects: [],
wasReset: false,
marquee: null
}
},
computed: {
rotate () {
return this.page.rotate || 0
},
displayWidth () {
return this.rotate % 180 ? this.metadata.height : this.metadata.width
},
displayHeight () {
return this.rotate % 180 ? this.metadata.width : this.metadata.height
},
pageStyle () {
return { aspectRatio: `${this.displayWidth} / ${this.displayHeight}` }
},
imageStyle () {
const scale = this.rotate % 180 ? this.metadata.width / this.metadata.height : 1
return {
transform: `translate(-50%, -50%) rotate(${this.rotate}deg) scale(${scale})`
}
},
overlayStyle () {
if (!this.rotate || !(this.rotate % 180)) {
return { inset: '0', transform: this.rotate ? `rotate(${this.rotate}deg)` : undefined }
}
return {
left: '50%',
top: '50%',
width: '100%',
aspectRatio: `${this.metadata.width} / ${this.metadata.height}`,
transform: `translate(-50%, -50%) rotate(${this.rotate}deg) scale(${this.metadata.width / this.metadata.height})`
}
},
hasRedactions () {
if (this.imagePage) {
return this.rects.length > 0
}
return Object.keys(this.selectedNodes).length > 0 || this.freeRects.length > 0
},
redactRects () {
if (this.imagePage) {
return this.rects
}
return this.textNodes ? [...this.buildRedactRects(), ...this.freeRects] : []
},
marqueeStyle () {
const left = Math.min(this.marquee.x1, this.marquee.x2)
const top = Math.min(this.marquee.y1, this.marquee.y2)
return {
left: `${left * 100}%`,
top: `${top * 100}%`,
width: `${Math.abs(this.marquee.x2 - this.marquee.x1) * 100}%`,
height: `${Math.abs(this.marquee.y2 - this.marquee.y1) * 100}%`
}
}
},
created () {
if (this.imagePage) {
this.rects = (this.page.redact || []).map((rect) => ({ ...rect }))
return
}
const cacheKey = `${this.page.sourceUuid}-${this.page.sourcePage}`
if (this.pageObjectsCache[cacheKey]) {
this.textNodes = this.pageObjectsCache[cacheKey].text_nodes
this.imageNodes = this.pageObjectsCache[cacheKey].image_nodes
this.preselectNodes()
return
}
const query = new URLSearchParams({ attachment_uuid: this.page.sourceUuid, page: this.page.sourcePage })
this.baseFetch(`/templates/${this.templateId}/documents_page_objects?${query}`).then(async (resp) => {
if (resp.ok) {
const data = await resp.json()
this.pageObjectsCache[cacheKey] = data
this.textNodes = data.text_nodes
this.imageNodes = data.image_nodes
this.preselectNodes()
} else {
this.$emit('cancel')
}
})
},
beforeUnmount () {
window.removeEventListener('mousemove', this.onMousemove)
window.removeEventListener('mouseup', this.onMouseup)
},
methods: {
inverseRotatePoint (point, rotate) {
let { x, y } = point
if (rotate === 90) {
[x, y] = [y, 1 - x]
} else if (rotate === 180) {
[x, y] = [1 - x, 1 - y]
} else if (rotate === 270) {
[x, y] = [1 - y, x]
}
return { x, y }
},
apply () {
this.$emit('apply', this.redactRects)
},
reset () {
this.wasReset = true
if (this.imagePage) {
this.rects = []
} else {
this.selectedNodes = {}
this.freeRects = []
}
},
boxesIntersect (a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
},
preselectNodes () {
const nodeRects = []
;(this.page.redact || []).forEach((rect) => {
if (this.imageNodes.some((node) => this.boxesIntersect(node, rect))) {
this.freeRects.push({ ...rect })
} else {
nodeRects.push(rect)
}
})
this.textNodes.forEach((node, index) => {
const centerX = node.x + (node.w / 2)
const centerY = node.y + (node.h / 2)
const isInside = nodeRects.some((rect) => {
return centerX >= rect.x && centerX <= rect.x + rect.w &&
centerY >= rect.y && centerY <= rect.y + rect.h
})
if (isInside) {
this.selectedNodes[index] = true
}
})
},
buildRedactRects () {
const nodes = this.textNodes.filter((_, index) => this.selectedNodes[index])
const sorted = nodes.slice().sort((a, b) => {
const diff = (a.y + (a.h / 2)) - (b.y + (b.h / 2))
return Math.abs(diff) < Math.min(a.h, b.h) / 2 ? a.x - b.x : diff
})
const rects = []
sorted.forEach((node) => {
const last = rects[rects.length - 1]
const sameLine = last &&
Math.abs((node.y + (node.h / 2)) - (last.y + (last.h / 2))) < Math.max(last.h, node.h) / 2
if (sameLine && node.x <= last.x + last.w + (node.h * 0.7)) {
const right = Math.max(last.x + last.w, node.x + node.w)
const bottom = Math.max(last.y + last.h, node.y + node.h)
last.x = Math.min(last.x, node.x)
last.y = Math.min(last.y, node.y)
last.w = right - last.x
last.h = bottom - last.y
} else {
rects.push({ x: node.x, y: node.y, w: node.w, h: node.h })
}
})
return rects
},
pagePoint (event) {
const rect = this.$refs.pageEl.getBoundingClientRect()
return {
x: Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1),
y: Math.min(Math.max((event.clientY - rect.top) / rect.height, 0), 1)
}
},
onMousedown (event) {
if (event.button !== 0 || (!this.imagePage && !this.textNodes)) {
return
}
const point = this.pagePoint(event)
this.marquee = { x1: point.x, y1: point.y, x2: point.x, y2: point.y }
window.addEventListener('mousemove', this.onMousemove)
window.addEventListener('mouseup', this.onMouseup, { once: true })
},
onMousemove (event) {
const point = this.pagePoint(event)
this.marquee.x2 = point.x
this.marquee.y2 = point.y
},
onMouseup () {
window.removeEventListener('mousemove', this.onMousemove)
if (!this.marquee) {
return
}
const start = this.inverseRotatePoint({ x: this.marquee.x1, y: this.marquee.y1 }, this.rotate)
const finish = this.inverseRotatePoint({ x: this.marquee.x2, y: this.marquee.y2 }, this.rotate)
const left = Math.min(start.x, finish.x)
const right = Math.max(start.x, finish.x)
const top = Math.min(start.y, finish.y)
const bottom = Math.max(start.y, finish.y)
this.marquee = null
if (right - left < 0.005 && bottom - top < 0.005) {
if (this.imagePage) {
const index = this.rects.findIndex((rect) => {
return left >= rect.x && left <= rect.x + rect.w && top >= rect.y && top <= rect.y + rect.h
})
if (index !== -1) {
this.rects.splice(index, 1)
}
} else {
const rectIndex = this.freeRects.findIndex((rect) => {
return left >= rect.x && left <= rect.x + rect.w && top >= rect.y && top <= rect.y + rect.h
})
if (rectIndex !== -1) {
this.freeRects.splice(rectIndex, 1)
} else {
const index = this.textNodes.findIndex((node) => {
return left >= node.x && left <= node.x + node.w && top >= node.y && top <= node.y + node.h
})
if (index !== -1) {
if (this.selectedNodes[index]) {
delete this.selectedNodes[index]
} else {
this.selectedNodes[index] = true
}
}
}
}
} else if (this.imagePage) {
this.rects.push({ x: left, y: top, w: right - left, h: bottom - top })
} else {
const marqueeBox = { x: left, y: top, w: right - left, h: bottom - top }
this.textNodes.forEach((node, index) => {
if (node.x < right && node.x + node.w > left && node.y < bottom && node.y + node.h > top) {
this.selectedNodes[index] = true
}
})
this.imageNodes.forEach((node) => {
if (!this.boxesIntersect(node, marqueeBox)) {
return
}
const x = Math.max(node.x, marqueeBox.x)
const y = Math.max(node.y, marqueeBox.y)
const w = Math.min(node.x + node.w, marqueeBox.x + marqueeBox.w) - x
const h = Math.min(node.y + node.h, marqueeBox.y + marqueeBox.h) - y
this.freeRects.push({ x, y, w, h })
})
}
}
}
}
</script>
+1 -1
View File
@@ -62,7 +62,7 @@
name="files[]"
:accept="acceptFileTypes"
multiple
@change="upload"
@change="upload()"
>
</form>
</label>
@@ -94,6 +94,8 @@ img.ProseMirror-separator {
}
dynamic-variable {
background-color: #fef3c7;
word-break: break-all;
overflow-wrap: anywhere;
}`)
function collectDomAttrs (dom) {
@@ -136,11 +138,12 @@ function collectSpanDomAttrs (dom) {
return result
}
function createBlockNode (name, tag, content) {
function createBlockNode (name, tag, content, extra = {}) {
return Node.create({
name,
group: 'block',
content: content || 'block+',
...extra,
addAttributes () {
return {
htmlAttrs: { default: {} }
@@ -194,9 +197,9 @@ const CustomHeading = Node.create({
})
const SectionNode = createBlockNode('section', 'section')
const ArticleNode = createBlockNode('article', 'article')
const HeaderNode = createBlockNode('header', 'header')
const FooterNode = createBlockNode('footer', 'footer')
const ArticleNode = createBlockNode('article', 'article', null, { isolating: true })
const HeaderNode = createBlockNode('header', 'header', null, { isolating: true })
const FooterNode = createBlockNode('footer', 'footer', null, { isolating: true })
const DivNode = createBlockNode('div', 'div')
const BlockquoteNode = createBlockNode('blockquote', 'blockquote')
const PreNode = createBlockNode('pre', 'pre')
@@ -248,7 +248,7 @@ export default {
FieldType,
IconSettings
},
inject: ['t', 'save', 'backgroundColor', 'dateFormats'],
inject: ['t', 'save', 'backgroundColor', 'dateFormats', 'locale'],
provide () {
return {
fieldTypes: ['text', 'number', 'date', 'checkbox', 'radio', 'select']
@@ -308,15 +308,18 @@ export default {
return { text: 'string', number: 'number', date: 'date', checkbox: 'boolean', radio: 'string', select: 'string' }
},
numberFormats () {
return [
'none',
'usd',
'eur',
'gbp',
'comma',
'dot',
'space'
]
const formats = ['none', 'usd', 'eur', 'gbp', 'comma', 'dot', 'space']
const spaceLocales = ['fr-FR', 'es-ES', 'pt-PT', 'de-DE', 'it-IT', 'nl-NL']
formats.push(spaceLocales.includes(this.locale) ? 'percent_space' : 'percent')
const selectedFormat = this.schema.format
if (selectedFormat && !formats.includes(selectedFormat)) {
formats.push(selectedFormat)
}
return formats
},
availableDateFormats () {
const formats = this.dateFormats.length
@@ -397,6 +400,10 @@ export default {
return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number)
} else if (format === 'percent') {
return `${number}%`
} else if (format === 'percent_space') {
return `${String(number).replace('.', ',')} %`
} else {
return number
}
@@ -514,7 +514,7 @@ export default {
ContextSubmenu,
ContextModal
},
inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies', 'dateFormats'],
inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies', 'dateFormats', 'locale'],
props: {
contextMenu: {
type: Object,
@@ -610,7 +610,7 @@ export default {
IconTypography,
IconX
},
inject: ['template', 't', 'dateFormats'],
inject: ['template', 't', 'dateFormats', 'locale'],
props: {
field: {
type: Object,
@@ -691,15 +691,18 @@ export default {
}
},
numberFormats () {
return [
'none',
'usd',
'eur',
'gbp',
'comma',
'dot',
'space'
]
const formats = ['none', 'usd', 'eur', 'gbp', 'comma', 'dot', 'space']
const spaceLocales = ['fr-FR', 'es-ES', 'pt-PT', 'de-DE', 'it-IT', 'nl-NL']
formats.push(spaceLocales.includes(this.locale) ? 'percent_space' : 'percent')
const selectedFormat = this.field.preferences?.format
if (selectedFormat && !formats.includes(selectedFormat)) {
formats.push(selectedFormat)
}
return formats
},
availableDateFormats () {
const formats = this.dateFormats.length
@@ -824,6 +827,10 @@ export default {
return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number)
} else if (format === 'percent') {
return `${number}%`
} else if (format === 'percent_space') {
return `${String(number).replace('.', ',')} %`
} else {
return number
}
@@ -0,0 +1,165 @@
<template>
<Teleport :to="modalContainerEl">
<div
class="modal modal-open items-start !animate-none overflow-y-auto"
>
<div
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title">
Google Drive
</span>
<a
href="#"
class="text-xl modal-close-button"
@click.prevent="$emit('close')"
>&times;</a>
</div>
<div>
<form
v-if="showOauthButton"
method="post"
:action="oauthPath"
@submit="isConnectClicked = true"
>
<input
type="hidden"
name="authenticity_token"
:value="authenticityToken"
autocomplete="off"
>
<button
class="btn bg-white btn-outline w-full text-base font-medium mt-4"
data-turbo="false"
type="submit"
:disabled="isConnectClicked"
>
<span v-if="isConnectClicked">
<span class="flex items-center justify-center space-x-2">
<IconInnerShadowTop class="animate-spin" />
<span>{{ t('submitting') }}...</span>
</span>
</span>
<span v-else>
<span class="flex items-center justify-center space-x-2">
<IconBrandGoogleDrive />
<span>{{ t('connect_google_drive') }}</span>
</span>
</span>
</button>
</form>
<div
v-else
class="relative"
>
<iframe
class="border border-base-300 rounded-lg"
style="width: 100%; height: 440px; background: white;"
src="/template_google_drive"
/>
<div v-if="loading">
<div
class="bg-white absolute top-0 bottom-0 left-0 right-0 opacity-80 rounded-lg"
style="margin: 1px"
/>
<div class="absolute top-0 bottom-0 left-0 right-0 flex items-center justify-center">
<IconInnerShadowTop class="animate-spin" />
</div>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script>
import { IconBrandGoogleDrive, IconInnerShadowTop } from '@tabler/icons-vue'
export default {
name: 'GoogleDrivePickerModal',
components: {
IconBrandGoogleDrive,
IconInnerShadowTop
},
inject: ['t'],
props: {
templateId: {
type: [Number, String],
required: true
},
authenticityToken: {
type: String,
required: false,
default: ''
},
reopenAfterAuth: {
type: Boolean,
required: false,
default: false
},
modalContainerEl: {
type: [Object, String],
required: true
},
loading: {
type: Boolean,
required: false,
default: true
}
},
emits: ['close', 'picked', 'update:loading'],
data () {
return {
isConnectClicked: false,
showOauthButton: false
}
},
computed: {
oauthPath () {
const redir = this.reopenAfterAuth
? `/templates/${this.templateId}/edit?google_drive_open=1`
: `/templates/${this.templateId}/edit`
const params = {
access_type: 'offline',
include_granted_scopes: 'true',
prompt: 'consent',
scope: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/drive.file'
].join(' '),
oauth_data: new URLSearchParams({ redir }).toString()
}
return `/auth/google_oauth2?${new URLSearchParams(params).toString()}`
}
},
mounted () {
window.addEventListener('message', this.messageHandler)
},
beforeUnmount () {
window.removeEventListener('message', this.messageHandler)
},
methods: {
messageHandler (event) {
if (event.data.type === 'google-drive-files-picked') {
const files = event.data.files || []
if (!files.length) return
this.$emit('update:loading', true)
this.$emit('picked', files)
} else if (event.data.type === 'google-drive-picker-loaded') {
this.$emit('update:loading', false)
} else if (event.data.type === 'google-drive-picker-request-oauth') {
this.showOauthButton = true
this.$emit('update:loading', false)
}
}
}
}
</script>
+141 -8
View File
@@ -96,6 +96,23 @@ const en = {
add_pdf_documents_or_images: 'Add PDF documents or images',
add_documents_or_images: 'Add documents or images',
add_a_new_document: 'Add a new document',
edit_documents: 'Edit documents',
move_forward: 'Move forward',
move_backward: 'Move backward',
remove_page: 'Remove page',
merge_with_previous: 'Merge with previous',
merge_with_next: 'Merge with next',
move_up: 'Move up',
move_down: 'Move down',
rotate: 'Rotate',
redact: 'Redact',
crop: 'Crop',
crop_and_scan: 'Crop & Scan',
flip_horizontal: 'Flip horizontal',
flip_vertical: 'Flip vertical',
there_is_no_text_to_redact_on_this_page: 'This page contains only images. Redact tool can be used only with text pages',
reset: 'Reset',
upload_to_document: 'Upload to "{document}"',
replace_existing_document: 'Replace existing document',
clone_and_replace_documents: 'Clone and replace documents',
required: 'Required',
@@ -219,7 +236,9 @@ const en = {
revisions: 'Revisions',
apply: 'Apply',
no_revisions_yet: 'No revisions yet',
viewing_revision_from: 'Viewing revision from {date}'
viewing_revision_from: 'Viewing revision from {date}',
connect_google_drive: 'Connect Google Drive',
submitting: 'Submitting'
}
const es = {
@@ -317,6 +336,23 @@ const es = {
add_pdf_documents_or_images: 'Agregar documentos PDF o imágenes',
add_documents_or_images: 'Agregar documentos o imágenes',
add_a_new_document: 'Agregar un nuevo documento',
edit_documents: 'Editar documentos',
move_forward: 'Mover adelante',
move_backward: 'Mover atrás',
remove_page: 'Eliminar página',
merge_with_previous: 'Combinar con el anterior',
merge_with_next: 'Combinar con el siguiente',
move_up: 'Mover arriba',
move_down: 'Mover abajo',
rotate: 'Rotar',
redact: 'Censurar',
crop: 'Recortar',
crop_and_scan: 'Recortar y escanear',
flip_horizontal: 'Voltear horizontal',
flip_vertical: 'Voltear vertical',
there_is_no_text_to_redact_on_this_page: 'Esta página contiene solo imágenes. La herramienta de censura solo puede usarse con páginas de texto',
reset: 'Restablecer',
upload_to_document: 'Subir a "{document}"',
replace_existing_document: 'Reemplazar documento existente',
clone_and_replace_documents: 'Clonar y reemplazar documentos',
required: 'Requerido',
@@ -443,7 +479,9 @@ const es = {
revisions: 'Revisiones',
apply: 'Aplicar',
no_revisions_yet: 'Aún no hay revisiones',
viewing_revision_from: 'Viendo revisión del {date}'
viewing_revision_from: 'Viendo revisión del {date}',
connect_google_drive: 'Conectar Google Drive',
submitting: 'Enviando'
}
const it = {
@@ -544,6 +582,23 @@ const it = {
add_pdf_documents_or_images: 'Aggiungi documenti PDF o immagini',
add_documents_or_images: 'Aggiungi documenti o immagini',
add_a_new_document: 'Aggiungi un nuovo documento',
edit_documents: 'Modifica documenti',
move_forward: 'Sposta avanti',
move_backward: 'Sposta indietro',
remove_page: 'Rimuovi pagina',
merge_with_previous: 'Unisci al precedente',
merge_with_next: 'Unisci al successivo',
move_up: 'Sposta su',
move_down: 'Sposta giù',
rotate: 'Ruota',
redact: 'Oscura',
crop: 'Ritaglia',
crop_and_scan: 'Ritaglia e scansiona',
flip_horizontal: 'Rifletti orizzontale',
flip_vertical: 'Rifletti verticale',
there_is_no_text_to_redact_on_this_page: 'Questa pagina contiene solo immagini. Lo strumento di oscuramento può essere usato solo con pagine di testo',
reset: 'Reimposta',
upload_to_document: 'Carica in "{document}"',
replace_existing_document: 'Sostituisci documento esistente',
clone_and_replace_documents: 'Clona e sostituisci documenti',
required: 'Obbligatorio',
@@ -667,7 +722,9 @@ const it = {
revisions: 'Revisioni',
apply: 'Applica',
no_revisions_yet: 'Nessuna revisione ancora',
viewing_revision_from: 'Visualizzazione revisione del {date}'
viewing_revision_from: 'Visualizzazione revisione del {date}',
connect_google_drive: 'Connetti Google Drive',
submitting: 'Invio'
}
const pt = {
@@ -679,7 +736,7 @@ const pt = {
analyzing_: 'Analisando...',
download: 'Baixar',
downloading_: 'Baixando...',
view: 'Visualizar',
view: 'Ver',
payment_link: 'Link de pagamento',
strikeout: 'Tachado',
draw_strikethrough_the_document: 'Desenhe uma linha de tachado no documento',
@@ -765,6 +822,23 @@ const pt = {
add_pdf_documents_or_images: 'Adicionar documentos PDF ou imagens',
add_documents_or_images: 'Adicionar documentos ou imagens',
add_a_new_document: 'Adicionar um novo documento',
edit_documents: 'Editar documentos',
move_forward: 'Mover para frente',
move_backward: 'Mover para trás',
remove_page: 'Remover página',
merge_with_previous: 'Mesclar com o anterior',
merge_with_next: 'Mesclar com o próximo',
move_up: 'Mover para cima',
move_down: 'Mover para baixo',
rotate: 'Girar',
redact: 'Censurar',
crop: 'Cortar',
crop_and_scan: 'Cortar e digitalizar',
flip_horizontal: 'Inverter horizontal',
flip_vertical: 'Inverter vertical',
there_is_no_text_to_redact_on_this_page: 'Esta página contém apenas imagens. A ferramenta de censura só pode ser usada com páginas de texto',
reset: 'Redefinir',
upload_to_document: 'Enviar para "{document}"',
replace_existing_document: 'Substituir documento existente',
clone_and_replace_documents: 'Clonar e substituir documentos',
required: 'Obrigatório',
@@ -891,7 +965,9 @@ const pt = {
revisions: 'Revisões',
apply: 'Aplicar',
no_revisions_yet: 'Nenhuma revisão ainda',
viewing_revision_from: 'Visualizando revisão de {date}'
viewing_revision_from: 'Visualizando revisão de {date}',
connect_google_drive: 'Conectar Google Drive',
submitting: 'Enviando'
}
const fr = {
@@ -992,6 +1068,23 @@ const fr = {
add_pdf_documents_or_images: 'Ajouter des documents PDF ou des images',
add_documents_or_images: 'Ajouter des documents ou des images',
add_a_new_document: 'Ajouter un nouveau document',
edit_documents: 'Modifier les documents',
move_forward: 'Déplacer en avant',
move_backward: 'Déplacer en arrière',
remove_page: 'Supprimer la page',
merge_with_previous: 'Fusionner avec le précédent',
merge_with_next: 'Fusionner avec le suivant',
move_up: 'Déplacer vers le haut',
move_down: 'Déplacer vers le bas',
rotate: 'Pivoter',
redact: 'Caviarder',
crop: 'Rogner',
crop_and_scan: 'Rogner et numériser',
flip_horizontal: 'Miroir horizontal',
flip_vertical: 'Miroir vertical',
there_is_no_text_to_redact_on_this_page: 'Cette page ne contient que des images. L\'outil de caviardage ne peut être utilisé qu\'avec des pages de texte',
reset: 'Réinitialiser',
upload_to_document: 'Téléverser dans "{document}"',
replace_existing_document: 'Remplacer le document existant',
clone_and_replace_documents: 'Cloner et remplacer des documents',
required: 'Obligatoire',
@@ -1115,7 +1208,9 @@ const fr = {
revisions: 'Révisions',
apply: 'Appliquer',
no_revisions_yet: 'Aucune révision pour le moment',
viewing_revision_from: 'Affichage de la révision du {date}'
viewing_revision_from: 'Affichage de la révision du {date}',
connect_google_drive: 'Connecter Google Drive',
submitting: 'Soumission'
}
const de = {
@@ -1216,6 +1311,23 @@ const de = {
add_pdf_documents_or_images: 'PDF-Dokumente oder Bilder hinzufügen',
add_documents_or_images: 'Dokumente oder Bilder hinzufügen',
add_a_new_document: 'Neues Dokument hinzufügen',
edit_documents: 'Dokumente bearbeiten',
move_forward: 'Nach vorne',
move_backward: 'Nach hinten',
remove_page: 'Seite entfernen',
merge_with_previous: 'Mit vorherigem zusammenführen',
merge_with_next: 'Mit nächstem zusammenführen',
move_up: 'Nach oben verschieben',
move_down: 'Nach unten verschieben',
rotate: 'Drehen',
redact: 'Schwärzen',
crop: 'Zuschneiden',
crop_and_scan: 'Zuschneiden & Scan',
flip_horizontal: 'Horizontal spiegeln',
flip_vertical: 'Vertikal spiegeln',
there_is_no_text_to_redact_on_this_page: 'Diese Seite enthält nur Bilder. Das Schwärzungswerkzeug kann nur mit Textseiten verwendet werden',
reset: 'Zurücksetzen',
upload_to_document: 'In "{document}" hochladen',
replace_existing_document: 'Vorhandenes Dokument ersetzen',
clone_and_replace_documents: 'Dokumente klonen und ersetzen',
required: 'Erforderlich',
@@ -1339,7 +1451,9 @@ const de = {
revisions: 'Revisionen',
apply: 'Anwenden',
no_revisions_yet: 'Noch keine Revisionen',
viewing_revision_from: 'Ansicht der Revision vom {date}'
viewing_revision_from: 'Ansicht der Revision vom {date}',
connect_google_drive: 'Google Drive verbinden',
submitting: 'Übermittlung'
}
const nl = {
@@ -1440,6 +1554,23 @@ const nl = {
add_pdf_documents_or_images: 'PDF-documenten of afbeeldingen toevoegen',
add_documents_or_images: 'Documenten of afbeeldingen toevoegen',
add_a_new_document: 'Nieuw document toevoegen',
edit_documents: 'Documenten bewerken',
move_forward: 'Naar voren',
move_backward: 'Naar achteren',
remove_page: 'Pagina verwijderen',
merge_with_previous: 'Samenvoegen met vorige',
merge_with_next: 'Samenvoegen met volgende',
move_up: 'Omhoog verplaatsen',
move_down: 'Omlaag verplaatsen',
rotate: 'Draaien',
redact: 'Redigeren',
crop: 'Bijsnijden',
crop_and_scan: 'Bijsnijden en scannen',
flip_horizontal: 'Horizontaal spiegelen',
flip_vertical: 'Verticaal spiegelen',
there_is_no_text_to_redact_on_this_page: 'Deze pagina bevat alleen afbeeldingen. De redactietool kan alleen worden gebruikt met tekstpagina\'s',
reset: 'Opnieuw instellen',
upload_to_document: 'Uploaden naar "{document}"',
replace_existing_document: 'Bestaand document vervangen',
clone_and_replace_documents: 'Documenten klonen en vervangen',
required: 'Vereist',
@@ -1563,7 +1694,9 @@ const nl = {
revisions: 'Revisies',
apply: 'Toepassen',
no_revisions_yet: 'Nog geen revisies',
viewing_revision_from: 'Revisie van {date} bekijken'
viewing_revision_from: 'Revisie van {date} bekijken',
connect_google_drive: 'Verbind Google Drive',
submitting: 'Indienen'
}
export { en, es, it, pt, fr, de, nl }
+1 -1
View File
@@ -11,7 +11,7 @@
:src="image.url"
:width="width"
:height="height"
class="rounded"
class="w-full h-full rounded"
@load="onImageLoad"
>
<div
+27 -4
View File
@@ -36,7 +36,10 @@
v-if="withReplaceButton"
:template-id="template.id"
:accept-file-types="acceptFileTypes"
class="opacity-0 group-hover:opacity-100"
:authenticity-token="authenticityToken"
:with-google-drive="withGoogleDrive"
:google-drive-file-id="item.google_drive_file_id"
class="opacity-0 group-hover:opacity-100 has-[label:focus]:opacity-100"
@click.stop
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
/>
@@ -69,6 +72,15 @@
style="min-width: 170px"
@click="closeDropdown"
>
<li v-if="!item.dynamic">
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm whitespace-nowrap"
@click.stop="$emit('edit', item); closeDropdown()"
>
<IconPencil class="w-4 h-4" />
<span>{{ t('edit') }}</span>
</button>
</li>
<li>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@@ -181,7 +193,7 @@
<script>
import Contenteditable from './contenteditable'
import Upload from './upload'
import { IconRouteAltLeft, IconSortDescending2, IconDotsVertical, IconTrashX, IconBolt, IconInnerShadowTop } from '@tabler/icons-vue'
import { IconRouteAltLeft, IconSortDescending2, IconDotsVertical, IconTrashX, IconBolt, IconInnerShadowTop, IconPencil } from '@tabler/icons-vue'
import ConditionsModal from './conditions_modal'
import ReplaceButton from './replace'
import GoogleDriveDocumentSettings from './google_drive_document_settings'
@@ -201,7 +213,8 @@ export default {
IconSortDescending2,
IconDotsVertical,
IconTrashX,
IconBolt
IconBolt,
IconPencil
},
inject: ['t', 'getFieldTypeIndex', 'baseFetch'],
props: {
@@ -232,6 +245,16 @@ export default {
required: true,
default: true
},
withGoogleDrive: {
type: Boolean,
required: false,
default: false
},
authenticityToken: {
type: String,
required: false,
default: ''
},
dynamicDocuments: {
type: Array,
required: true
@@ -247,7 +270,7 @@ export default {
default: true
}
},
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace', 'reorder'],
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace', 'reorder', 'edit'],
data () {
return {
isShowConditionsModal: false,
+109 -11
View File
@@ -1,37 +1,106 @@
<template>
<label
:for="inputId"
class="btn btn-neutral btn-xs text-white transition-none replace-document-button"
:class="{ 'opacity-100': isLoading }"
>
{{ message }}
<div class="inline-flex items-stretch replace-document-control">
<label
:for="inputId"
class="btn btn-neutral btn-xs transition-none replace-document-button"
:class="{ 'opacity-100': isLoading, 'pr-0': showGoogleDriveDropdown && !isLoading }"
>
<div class="flex items-center justify-between w-full h-full">
<span class="flex items-center space-x-2 w-full justify-center text-white">
{{ message }}
</span>
<span
v-if="showGoogleDriveDropdown && !isLoading"
class="dropdown dropdown-end inline h-full"
style="width: 30px"
>
<label
tabindex="0"
class="flex items-center h-full cursor-pointer text-white"
>
<IconChevronDown class="w-4 h-4 flex-shrink-0" />
</label>
<ul
tabindex="0"
:style="{ backgroundColor }"
class="dropdown-content p-2 mt-2 shadow menu text-base mb-1 rounded-box text-right !text-base-content"
>
<li>
<button
type="button"
@click.prevent="openGoogleDriveModal"
>
<IconBrandGoogleDrive class="w-4 h-4 flex-shrink-0" />
<span class="whitespace-nowrap text-sm normal-case font-medium">Google Drive</span>
</button>
</li>
</ul>
</span>
</div>
</label>
<form
ref="form"
class="hidden"
>
<input
v-if="googleDriveFile"
name="google_drive_file_ids[]"
:value="googleDriveFile.id"
>
<input
:id="inputId"
ref="input"
name="files[]"
type="file"
:accept="acceptFileTypes"
@change="upload"
@change="upload()"
>
</form>
</label>
<GoogleDrivePickerModal
v-if="showGoogleDriveModal"
v-model:loading="isLoadingGoogleDrive"
:template-id="templateId"
:authenticity-token="authenticityToken"
:modal-container-el="modalContainerEl"
@close="showGoogleDriveModal = false"
@picked="onGoogleDrivePicked"
/>
</div>
</template>
<script>
import Upload from './upload'
import GoogleDrivePickerModal from './google_drive_picker_modal'
import { IconChevronDown, IconBrandGoogleDrive } from '@tabler/icons-vue'
export default {
name: 'ReplaceDocument',
inject: ['baseFetch', 't'],
components: {
IconChevronDown,
IconBrandGoogleDrive,
GoogleDrivePickerModal
},
inject: ['baseFetch', 't', 'backgroundColor'],
props: {
templateId: {
type: [Number, String],
required: true
},
authenticityToken: {
type: String,
required: false,
default: ''
},
googleDriveFileId: {
type: String,
required: false,
default: ''
},
withGoogleDrive: {
type: Boolean,
required: false,
default: false
},
acceptFileTypes: {
type: String,
required: false,
@@ -41,16 +110,25 @@ export default {
emits: ['success'],
data () {
return {
isLoading: false
isLoading: false,
isLoadingGoogleDrive: true,
googleDriveFile: null,
showGoogleDriveModal: false
}
},
computed: {
inputId () {
return 'el' + Math.random().toString(32).split('.')[1]
},
showGoogleDriveDropdown () {
return this.withGoogleDrive && !!this.googleDriveFileId
},
uploadUrl () {
return `/templates/${this.templateId}/documents`
},
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
message () {
if (this.isLoading) {
return this.t('uploading_')
@@ -60,7 +138,27 @@ export default {
}
},
methods: {
upload: Upload.methods.upload
upload: Upload.methods.upload,
openGoogleDriveModal () {
this.showGoogleDriveModal = true
this.isLoadingGoogleDrive = true
this.googleDriveFile = null
this.$el.getRootNode().activeElement?.blur()
},
onGoogleDrivePicked (files) {
this.googleDriveFile = files[0]
this.$nextTick(() => {
this.upload({ path: `/templates/${this.templateId}/google_drive_documents` }).then((resp) => {
if (resp.ok) {
this.showGoogleDriveModal = false
}
}).finally(() => {
this.isLoadingGoogleDrive = false
this.googleDriveFile = null
})
})
}
}
}
</script>
+97 -127
View File
@@ -61,87 +61,16 @@
</span>
</div>
</label>
<Teleport
<GoogleDrivePickerModal
v-if="showGoogleDriveModal"
:to="modalContainerEl"
>
<div
class="modal modal-open items-start !animate-none overflow-y-auto"
>
<div
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="showGoogleDriveModal = false"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title">
Google Drive
</span>
<a
href="#"
class="text-xl modal-close-button"
@click.prevent="showGoogleDriveModal = false"
>&times;</a>
</div>
<div>
<form
v-if="showGoogleDriveOauthButton"
method="post"
:action="googleDriveOauthPath"
@submit="isConnectGoogleDriveClicked = true"
>
<input
type="hidden"
name="authenticity_token"
:value="authenticityToken"
autocomplete="off"
>
<button
id="gdrive_oauth_button"
class="btn bg-white btn-outline w-full text-base font-medium mt-4"
data-turbo="false"
type="submit"
:disabled="isConnectGoogleDriveClicked"
>
<span v-if="isConnectGoogleDriveClicked">
<span class="flex items-center justify-center space-x-2">
<IconInnerShadowTop class="animate-spin" />
<span class="">Submitting...</span>
</span>
</span>
<span
v-else
>
<span class="flex items-center justify-center space-x-2">
<IconBrandGoogleDrive />
<span>Connect Google Drive</span>
</span>
</span>
</button>
</form>
<div
v-else
class="relative"
>
<iframe
class="border border-base-300 rounded-lg"
style="width: 100%; height: 440px; background: white;"
src="/template_google_drive"
/>
<div v-if="isLoadingGoogleDrive">
<div
class="bg-white absolute top-0 bottom-0 left-0 right-0 opacity-80 rounded-lg"
style="margin: 1px"
/>
<div class="absolute top-0 bottom-0 left-0 right-0 flex items-center justify-center">
<IconInnerShadowTop class="animate-spin" />
</div>
</div>
</div>
</div>
</div>
</div>
</Teleport>
v-model:loading="isLoadingGoogleDrive"
:template-id="templateId"
:authenticity-token="authenticityToken"
:modal-container-el="modalContainerEl"
:reopen-after-auth="true"
@close="showGoogleDriveModal = false"
@picked="onGoogleDrivePicked"
/>
<form
ref="form"
class="hidden"
@@ -159,7 +88,7 @@
type="file"
:accept="acceptFileTypes"
multiple
@change="upload"
@change="upload()"
>
</form>
</div>
@@ -167,6 +96,74 @@
<script>
import { IconUpload, IconInnerShadowTop, IconChevronDown, IconBrandGoogleDrive } from '@tabler/icons-vue'
import GoogleDrivePickerModal from './google_drive_picker_modal'
function convertImage (sourceFile, targetType, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
resolve(newFile)
}, targetType, quality)
}
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(sourceFile)
})
}
export async function convertUnsupportedImages (files) {
const converted = []
for (const file of Array.from(files)) {
let result = file
try {
if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
result = await convertImage(file, 'image/png')
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
result = await convertImage(file, 'image/jpeg', 0.9)
}
} catch (e) {
alert(e.message)
}
converted.push(result)
}
return converted
}
async function convertImagesInInput (input) {
if (!input.files || input.files.length === 0) return
const originals = Array.from(input.files)
const converted = await convertUnsupportedImages(originals)
if (converted.some((file, index) => file !== originals[index])) {
const dt = new DataTransfer()
converted.forEach((file) => dt.items.add(file))
input.files = dt.files
}
}
export default {
name: 'DocumentsUpload',
@@ -174,7 +171,8 @@ export default {
IconUpload,
IconInnerShadowTop,
IconChevronDown,
IconBrandGoogleDrive
IconBrandGoogleDrive,
GoogleDrivePickerModal
},
inject: ['baseFetch', 't', 'backgroundColor'],
props: {
@@ -202,11 +200,9 @@ export default {
data () {
return {
isLoading: false,
isConnectGoogleDriveClicked: false,
isLoadingGoogleDrive: false,
isLoadingGoogleDrive: true,
googleDriveFiles: [],
showGoogleDriveModal: false,
showGoogleDriveOauthButton: false
showGoogleDriveModal: false
}
},
computed: {
@@ -219,69 +215,43 @@ export default {
uploadUrl () {
return `/templates/${this.templateId}/documents`
},
googleDriveOauthPath () {
const params = {
access_type: 'offline',
include_granted_scopes: 'true',
prompt: 'consent',
scope: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/drive.file'
].join(' '),
oauth_data: new URLSearchParams({
redir: `/templates/${this.templateId}/edit?google_drive_open=1`
}).toString()
}
const query = new URLSearchParams(params).toString()
return `/auth/google_oauth2?${query}`
},
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
}
},
mounted () {
window.addEventListener('message', this.messageHandler)
if (this.queryParams.get('google_drive_open') === '1') {
this.openGoogleDriveModal()
window.history.replaceState({}, document.title, window.location.pathname)
}
},
beforeUnmount () {
window.removeEventListener('message', this.messageHandler)
},
methods: {
openGoogleDriveModal () {
this.showGoogleDriveModal = true
this.isLoadingGoogleDrive = true
},
messageHandler (event) {
if (event.data.type === 'google-drive-files-picked') {
this.googleDriveFiles = event.data.files || []
onGoogleDrivePicked (files) {
this.googleDriveFiles = files
this.$nextTick(() => {
this.isLoadingGoogleDrive = true
this.upload({ path: `/templates/${this.templateId}/google_drive_documents` }).then((resp) => {
if (resp.ok) {
this.showGoogleDriveModal = false
}
}).finally(() => {
this.isLoadingGoogleDrive = false
})
this.$nextTick(() => {
this.upload({ path: `/templates/${this.templateId}/google_drive_documents` }).then((resp) => {
if (resp.ok) {
this.showGoogleDriveModal = false
}
}).finally(() => {
this.isLoadingGoogleDrive = false
this.googleDriveFiles = []
})
} else if (event.data.type === 'google-drive-picker-loaded') {
this.isLoadingGoogleDrive = false
} else if (event.data.type === 'google-drive-picker-request-oauth') {
this.showGoogleDriveOauthButton = true
}
})
},
async upload ({ path } = {}) {
this.isLoading = true
if (this.$refs.input) {
await convertImagesInInput(this.$refs.input)
}
return this.baseFetch(path || this.uploadUrl, {
method: 'POST',
headers: { Accept: 'application/json' },
-2
View File
@@ -3,8 +3,6 @@
class ReindexSearchEntryJob
include Sidekiq::Job
InvalidFormat = Class.new(StandardError)
def perform(params = {})
entry = SearchEntry.find_or_initialize_by(params.slice('record_type', 'record_id'))
+6
View File
@@ -28,6 +28,12 @@ class DynamicDocument < ApplicationRecord
has_many :versions, class_name: 'DynamicDocumentVersion', dependent: :destroy
has_one :current_version, class_name: 'DynamicDocumentVersion',
primary_key: %i[id sha1],
foreign_key: %i[dynamic_document_id sha1],
dependent: :destroy,
inverse_of: :dynamic_document
attribute :fields, :json
before_validation :set_sha1
+1 -1
View File
@@ -75,7 +75,7 @@ class Template < ApplicationRecord
has_many :dynamic_document_versions, through: :dynamic_documents, source: :versions
has_many :schema_dynamic_documents, lambda { |e|
where(uuid: e.schema.select { |e| e['dynamic'] }.pluck('attachment_uuid'))
where(uuid: e.schema.select { |item| item['dynamic'] }.pluck('attachment_uuid'))
}, class_name: 'DynamicDocument', dependent: :destroy, inverse_of: :template
scope :active, -> { where(archived_at: nil) }
+2 -2
View File
@@ -36,7 +36,7 @@
<% if signature %>
<div class="flex justify-center mb-4 relative">
<%= button_to button_title(title: t('remove'), disabled_with: t('removing')), user_signature_path, method: :delete, class: 'right-0 top-0 absolute link' %>
<img src="<%= signature.url %>" style="max-height: 200px; width: auto" width="<%= signature.metadata['width'] %>" height="<%= signature.metadata['height'] %>">
<img src="<%= signature.url %>" style="height: 130px; width: 100%; object-fit: contain;">
</div>
<% end %>
<a href="<%= edit_user_signature_path %>" data-turbo-frame="modal" class="base-button w-full">
@@ -49,7 +49,7 @@
<% if initials %>
<div class="flex justify-center mb-4 relative">
<%= button_to button_title(title: t('remove'), disabled_with: t('removing')), user_initials_path, method: :delete, class: 'right-0 top-0 absolute link' %>
<img src="<%= initials.url %>" style="max-height: 200px; width: auto" width="<%= initials.metadata['width'] %>" height="<%= initials.metadata['height'] %>">
<img src="<%= initials.url %>" style="height: 130px; width: 100%; object-fit: contain;">
</div>
<% end %>
<a href="<%= edit_user_initials_path %>" data-turbo-frame="modal" class="base-button w-full">
+1 -1
View File
@@ -1,6 +1,6 @@
<a target="_blank" href="<%= Docuseal::GITHUB_URL %>" rel="noopener noreferrer nofollow" class="relative flex items-center rounded-full px-2 py-0.5 text-xs leading-4 mt-1 text-base-content border border-base-300 tooltip tooltip-bottom" data-tip="Give a star on GitHub">
<span class="flex items-center justify-between space-x-0.5 font-medium">
<%= svg_icon('start', class: 'h-3 w-3') %>
<span>16k</span>
<span>17k</span>
</span>
</a>
+1 -3
View File
@@ -52,9 +52,7 @@
<% if link_form_fields.include?('phone') %>
<div dir="auto" class="form-control !mt-0">
<%= f.label :phone, t('phone'), class: 'label' %>
<custom-validation data-invalid-message="<%= t('use_international_format_1xxx_') %>">
<%= f.telephone_field :phone, value: params[:phone] || @submitter.phone, pattern: '^\+[0-9\s\-]+$', required: true, class: 'base-input w-full', placeholder: t(multiple_fields ? 'provide_your_phone_in_international_format' : 'provide_your_phone_in_international_format_to_start') %>
</custom-validation>
<%= f.telephone_field :phone, value: params[:phone] || @submitter.phone, pattern: '^\+[0-9\s\-]+$', required: true, title: t('use_international_format_1xxx_'), class: 'base-input w-full', placeholder: t(multiple_fields ? 'provide_your_phone_in_international_format' : 'provide_your_phone_in_international_format_to_start') %>
</div>
<% end %>
<toggle-submit dir="auto" class="form-control">
+10 -14
View File
@@ -34,13 +34,11 @@
</linked-input>
</submitters-autocomplete>
<% has_phone_field = true %>
<custom-validation data-invalid-message="<%= t('use_international_format_1xxx_') %>">
<submitters-autocomplete data-field="phone">
<linked-input data-target-id="<%= "detailed_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', name: 'submission[1][submitters][][phone]', autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: local_assigns[:require_phone_2fa] == true || local_assigns[:prefillable_fields].present? ? t(:phone) : "#{t('phone')} (#{t('optional')})", id: "detailed_phone_#{item['uuid']}", required: local_assigns[:require_phone_2fa] == true && index.zero? %>
</linked-input>
</submitters-autocomplete>
</custom-validation>
<submitters-autocomplete data-field="phone">
<linked-input data-target-id="<%= "detailed_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', name: 'submission[1][submitters][][phone]', autocomplete: 'off', title: t('use_international_format_1xxx_'), class: 'base-input !h-10 mt-1.5 w-full', placeholder: local_assigns[:require_phone_2fa] == true || local_assigns[:prefillable_fields].present? ? t(:phone) : "#{t('phone')} (#{t('optional')})", id: "detailed_phone_#{item['uuid']}", required: local_assigns[:require_phone_2fa] == true && index.zero? %>
</linked-input>
</submitters-autocomplete>
</div>
<% end %>
<% if prefillable_fields.present? %>
@@ -51,13 +49,11 @@
</submitters-autocomplete>
<% if local_assigns[:require_phone_2fa] == true || prefillable_fields.any? { |f| f['type'] == 'phone' } %>
<% has_phone_field = true %>
<custom-validation data-invalid-message="<%= t('use_international_format_1xxx_') %>">
<submitters-autocomplete data-field="phone">
<linked-input data-target-id="<%= "detailed_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', name: 'submission[1][submitters][][phone]', autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: t(:phone), id: "detailed_phone_#{item['uuid']}", required: true %>
</linked-input>
</submitters-autocomplete>
</custom-validation>
<submitters-autocomplete data-field="phone">
<linked-input data-target-id="<%= "detailed_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', name: 'submission[1][submitters][][phone]', autocomplete: 'off', title: t('use_international_format_1xxx_'), class: 'base-input !h-10 mt-1.5 w-full', placeholder: t(:phone), id: "detailed_phone_#{item['uuid']}", required: true %>
</linked-input>
</submitters-autocomplete>
<% end %>
<% prefillable_fields.each do |field| %>
<% field_id = "detailed_field_#{index}_#{field['uuid'] || field['name'].parameterize}" %>
+5 -7
View File
@@ -19,13 +19,11 @@
</label>
<% end %>
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<custom-validation data-invalid-message="<%= t('use_international_format_1xxx_') %>">
<submitters-autocomplete data-field="phone">
<linked-input data-target-id="<%= "phone_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', name: 'submission[1][submitters][][phone]', autocomplete: 'off', class: 'base-input !h-10 w-full', placeholder: t('phone'), required: index.zero? || template.preferences['require_all_submitters'], id: "phone_phone_#{item['uuid']}" %>
</linked-input>
</submitters-autocomplete>
</custom-validation>
<submitters-autocomplete data-field="phone">
<linked-input data-target-id="<%= "phone_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', name: 'submission[1][submitters][][phone]', autocomplete: 'off', title: t('use_international_format_1xxx_'), class: 'base-input !h-10 w-full', placeholder: t('phone'), required: index.zero? || template.preferences['require_all_submitters'], id: "phone_phone_#{item['uuid']}" %>
</linked-input>
</submitters-autocomplete>
<% if submitters.size > 1 %>
<submitters-autocomplete data-field="name">
<linked-input data-target-id="<%= "phone_name_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
+11 -3
View File
@@ -30,6 +30,12 @@
<span class="hidden md:block"><%= t('event_log') %></span>
<% end %>
<% end %>
<% if signed_in? && !is_all_completed && can?(:manage, :resend_all) && @submission.submitters.to_a.size > 3 && !@submission.archived_at? && !@submission.template&.archived_at? && !@submission.expired? && can?(:update, @submission) %>
<% pending_submitters_count = @submission.submitters.to_a.count { |s| !s.completed_at? && s.email.present? && !s.declined_at? } %>
<% if pending_submitters_count.positive? %>
<%= button_to button_title(title: t('re_send_emails'), title_class: 'hidden md:inline', disabled_with: t('sending'), icon: svg_icon('mail_forward', class: 'w-6 h-6')), submission_resend_email_index_path(@submission), class: 'white-button', data: { turbo_confirm: t('are_you_sure_you_want_to_re_send_email_to_n_recipients', count: pending_submitters_count) } %>
<% end %>
<% end %>
<% if @submission.submitters.to_a.any?(&:completed_at?) %>
<% if is_all_completed || !is_combined_enabled %>
<div class="join relative">
@@ -221,7 +227,7 @@
<div class="flex items-center space-x-1 mt-1">
<span>
<%= t('reason') %>:
<%= simple_format(h(submitter.submission_events.find_by(event_type: :decline_form).data['reason'])) %>
<%= simple_format(h(submitter.submission_events.find_by(event_type: :decline_form).data['reason']), {}, sanitize: false) %>
</span>
</div>
<% end %>
@@ -260,10 +266,12 @@
<div dir="auto">
<% if field['type'].in?(%w[signature initials]) %>
<div class="w-full bg-base-300 py-1">
<img class="object-contain mx-auto" style="max-height: <%= field['type'] == 'signature' ? 100 : 50 %>px" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy" alt="<%= field['name'] || field['title'] || field['type'] %>">
<% img_height = attachments_index[value].metadata['height'] %>
<img class="object-contain mx-auto" style="<%= 'max-' if img_height %>height: <%= field['type'] == 'signature' ? 100 : 50 %>px" height="<%= img_height %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy" alt="<%= field['name'] || field['title'] || field['type'] %>">
</div>
<% elsif field['type'].in?(['image', 'stamp', 'kba']) && attachments_index[value].image? %>
<img class="object-contain mx-auto max-h-28" style="max-height: 200px" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy" alt="<%= field['name'] || field['title'] || field['type'] %>">
<% img_height = attachments_index[value].metadata['height'] %>
<img class="object-contain mx-auto" style="<%= 'max-' if img_height %>height: 200px" height="<%= img_height %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy" alt="<%= field['name'] || field['title'] || field['type'] %>">
<% elsif field['type'].in?(['file', 'payment', 'image']) %>
<div class="flex flex-col justify-center">
<% Array.wrap(value).each do |val| %>
+32 -19
View File
@@ -21,20 +21,31 @@
<h1 class="text-xl md:text-2xl font-medium focus:text-clip" style="width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<%= @submitter.submission.name || @submitter.submission.template.name %>
</h1>
<div class="flex items-center space-x-2" style="margin-left: 20px; flex-shrink: 0">
<div class="flex items-center gap-2 group" style="margin-left: 20px; flex-shrink: 0">
<% if @form_configs[:with_decline] %>
<modal-button data-target="<%= decline_modal_id = SecureRandom.uuid %>" class="hidden group-has-[.complete-button]:flex">
<button type="button" class="btn btn-sm md:!px-5 px-2" aria-label="<%= t(:decline) %>">
<span class="hidden md:inline"><%= t(:decline) %></span>
<span class="inline md:hidden">
<%= svg_icon('x', class: 'w-5 h-5') %>
</span>
</button>
</modal-button>
<% end %>
<span id="complete_button_container" class="peer contents"></span>
<% if @form_configs[:with_delegate] %>
<modal-button data-target="<%= delegate_modal_id = SecureRandom.uuid %>">
<modal-button data-target="<%= delegate_modal_id = SecureRandom.uuid %>" class="hidden peer-empty:flex">
<button id="delegate_button" type="button" class="btn btn-sm !px-5"><%= t(:delegate) %></button>
</modal-button>
<% if @form_configs[:with_decline] %>
<modal-button data-target="<%= decline_modal_id = SecureRandom.uuid %>">
<modal-button data-target="<%= decline_modal_id %>" class="hidden peer-empty:flex">
<button id="decline_button" type="button" class="btn btn-sm px-2" aria-label="<%= t(:decline) %>">
<%= svg_icon('x', class: 'w-5 h-5') %>
</button>
</modal-button>
<% end %>
<% if @form_configs[:with_partial_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" title="<%= t('download') %>" 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 hidden peer-empty:flex" title="<%= t('download') %>" aria-label="<%= t('download') %>">
<span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %>
</span>
@@ -45,12 +56,12 @@
<% end %>
<% else %>
<% if @form_configs[:with_decline] %>
<modal-button data-target="<%= decline_modal_id = SecureRandom.uuid %>">
<modal-button data-target="<%= decline_modal_id %>" class="hidden peer-empty:flex">
<button id="decline_button" type="button" class="btn btn-sm !px-5"><%= t(:decline) %></button>
</modal-button>
<% end %>
<% if @form_configs[:with_partial_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-4" 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-4 hidden peer-empty:flex" aria-label="<%= t('download') %>">
<span class="flex items-center justify-center" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6 inline md:hidden') %>
<span class="hidden md:inline"><%= t('download') %></span>
@@ -64,9 +75,17 @@
<% end %>
</div>
</header>
<scroll-buttons inert 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 group">
<% if @form_configs[:with_decline] %>
<modal-button data-target="<%= decline_modal_id %>" class="hidden group-has-[.complete-button]:flex">
<button type="button" class="btn btn-sm px-2" aria-label="<%= t(:decline) %>">
<%= svg_icon('x', class: 'w-5 h-5') %>
</button>
</modal-button>
<% end %>
<span id="complete_button_container_scroll" class="peer contents"></span>
<% if @form_configs[:with_delegate] %>
<modal-button data-target="<%= delegate_modal_id %>">
<modal-button data-target="<%= delegate_modal_id %>" class="hidden peer-empty:flex">
<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">
<%= t(:delegate) %>
@@ -77,23 +96,15 @@
</button>
</modal-button>
<% if @form_configs[:with_decline] %>
<modal-button data-target="<%= decline_modal_id %>">
<modal-button data-target="<%= decline_modal_id %>" class="hidden peer-empty:flex">
<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') %>
</button>
</modal-button>
<% end %>
<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">
<%= svg_icon('download', class: 'w-5 h-5') %>
</span>
<span class="hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
</span>
</download-button>
<% else %>
<% if @form_configs[:with_decline] %>
<modal-button data-target="<%= decline_modal_id %>">
<modal-button data-target="<%= decline_modal_id %>" class="hidden peer-empty:flex">
<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">
<%= t(:decline) %>
@@ -104,7 +115,9 @@
</button>
</modal-button>
<% end %>
<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') %>">
<% end %>
<% if @form_configs[:with_partial_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 hidden peer-empty:flex" aria-label="<%= t('download') %>">
<span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %>
</span>
@@ -1,4 +1,4 @@
<p><%= t('hi_there') %>,</p>
<p><%= t('name_declined_by_submitter_with_the_following_reason', name: @submitter.submission.name || @submitter.submission.template.name, submitter: @submitter.name || @submitter.email || @submitter.phone) %></p>
<%= simple_format(h(@submitter.submission_events.find_by(event_type: :decline_form).data['reason'])) %>
<%= simple_format(h(@submitter.submission_events.find_by(event_type: :decline_form).data['reason']), {}, sanitize: false) %>
<p><%= link_to submission_url(@submitter.submission), submission_url(@submitter.submission) %></p>
+3 -5
View File
@@ -10,11 +10,9 @@
<submitters-autocomplete data-field="email">
<%= email_field_tag 'submitter[email]', @submitter.email, autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: "#{t('email')} (#{t('optional')})" %>
</submitters-autocomplete>
<custom-validation data-invalid-message="<%= t('use_international_format_1xxx_') %>">
<submitters-autocomplete data-field="phone">
<%= telephone_field_tag 'submitter[phone]', @submitter.phone, autocomplete: 'off', pattern: '^\+[0-9\s\-]+$', class: 'base-input !h-10 mt-1.5 w-full', placeholder: "#{t('phone')} (#{t('optional')})" %>
</submitters-autocomplete>
</custom-validation>
<submitters-autocomplete data-field="phone">
<%= telephone_field_tag 'submitter[phone]', @submitter.phone, autocomplete: 'off', pattern: '^\+[0-9\s\-]+$', title: t('use_international_format_1xxx_'), class: 'base-input !h-10 mt-1.5 w-full', placeholder: "#{t('phone')} (#{t('optional')})" %>
</submitters-autocomplete>
</div>
</submitter-item>
</div>
+2 -2
View File
@@ -60,7 +60,7 @@
</div>
<div class="flex flex-wrap gap-2 w-full md:w-fit md:justify-between md:flex-none md:pt-1">
<% if !template.archived_at? && can?(:destroy, template) %>
<%= button_to button_title(title: t('archive'), disabled_with: t('archiving'), title_class: 'inline', icon: svg_icon('archive', class: 'w-6 h-6')), template_path(template), class: 'btn btn-outline btn-sm w-full', form_class: 'flex-1', method: :delete, data: { turbo_confirm: t('are_you_sure_') } %>
<%= button_to button_title(title: t('archive'), disabled_with: t('archiving')[..-4], title_class: 'inline', icon: svg_icon('archive', class: 'w-6 h-6')), template_path(template), class: 'btn btn-outline btn-sm w-full', form_class: 'flex-1', method: :delete %>
<% end %>
<% if can?(:create, current_account.templates.new(author: current_user)) %>
<%= link_to new_template_clone_path(template), class: 'btn btn-outline btn-sm flex-1', data: { turbo_frame: :modal } do %>
@@ -89,7 +89,7 @@
<% end %>
<% if template.archived_at? %>
<% if can?(:create, template) %>
<%= button_to button_title(title: t('restore'), disabled_with: t('restoring'), icon: svg_icon('rotate', class: 'w-6 h-6')), template_restore_index_path(template), class: 'btn btn-outline btn-sm flex-1' %>
<%= button_to button_title(title: t('restore'), disabled_with: t('restoring')[..-4], icon: svg_icon('rotate', class: 'w-6 h-6')), template_restore_index_path(template), class: 'btn btn-outline btn-sm flex-1' %>
<% end %>
<%= link_to template_preview_path(template), class: 'btn btn-outline btn-sm flex-1' do %>
<span class="flex items-center justify-center space-x-2">
+2 -2
View File
@@ -15,8 +15,8 @@
</span>
</label>
<input type="hidden" name="form_id" value="<%= form_id %>">
<submit-form data-on="change" data-disable="true">
<convert-upload>
<input id="upload_template" name="files[]" class="hidden" type="file" accept="image/*, application/pdf, application/zip, application/json<%= ", #{Templates::CreateAttachments::DOCUMENT_EXTENSIONS.join(', ')}" if Docuseal.advanced_formats? %>" multiple>
</submit-form>
</convert-upload>
<input hidden name="folder_name" value="<%= local_assigns[:folder_name] %>">
<% end %>
+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-custom-fields="<%= (current_account.account_configs.find_or_initialize_by(key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY).value || []).to_json %>" 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-with-revisions-menu="<%= @template.template_versions.exists? %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
<template-builder class="grid" data-template="<%= @template_data.to_json %>" data-custom-fields="<%= (current_account.account_configs.find_or_initialize_by(key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY).value || []).to_json %>" 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-with-revisions-menu="<%= @template.template_versions.exists? %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
+1 -1
View File
@@ -1 +1 @@
<template-builder class="grid" data-editable="false" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-template="<%= @template_data %>"></template-builder>
<template-builder class="grid" data-editable="false" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-template="<%= @template_data.to_json %>"></template-builder>
+28 -16
View File
@@ -1,18 +1,30 @@
<div class="h-screen">
<div
class="text-center p-8 h-full flex items-center justify-center">
<div>
<%= render 'shared/logo', width: 50, height: 50, class: 'mx-auto animate-bounce' %>
<span>
<%= t('processing') %>...
</span>
<confirm-upload>
<div class="h-screen">
<div class="text-center p-8 h-full flex items-center justify-center">
<div class="space-y-4">
<div data-target="confirm-upload.logo" class="mx-auto">
<%= render 'shared/logo', width: 50, height: 50, class: 'mx-auto' %>
</div>
<div data-target="confirm-upload.processing" class="hidden">
<span><%= t('processing') %>...</span>
</div>
<div data-target="confirm-upload.prompt">
<p class="text-lg">
<%= t('open_file_from') %>
<a href="<%= params[:url] %>" target="_blank" class="link" rel="noopener noreferrer nofollow">
<%= params[:filename].presence || params[:url] %>
</a>
</p>
<div class="flex items-center justify-center gap-2 mt-4">
<%= link_to t('cancel'), root_path, class: 'white-button w-44' %>
<%= form_for '', url: templates_upload_path, method: :post do |f| %>
<input type="hidden" name="url" value="<%= params[:url] %>">
<input type="hidden" name="filename" value="<%= params[:filename] %>">
<%= f.button button_title(title: t('open')), class: 'base-button w-44' %>
<% end %>
</div>
</div>
</div>
</div>
</div>
</div>
<submit-form>
<%= form_for '', url: templates_upload_path, method: :post, class: 'hidden' do %>
<button type="submit"></button>
<input name="url" value="<%= params[:url] %>">
<input name="filename" value="<%= params[:filename] %>">
<% end %>
</submit-form>
</confirm-upload>
+12
View File
@@ -25,6 +25,18 @@ module DocuSeal
config.active_storage.draw_routes = ENV['MULTITENANT'] != 'true'
config.active_storage.analyzers = []
config.active_storage.variant_processor = :disabled
config.active_storage.content_types_to_serve_as_binary += %w[
application/javascript
text/javascript
application/ecmascript
text/ecmascript
application/wasm
]
config.i18n.available_locales = %i[en en-US en-GB es-ES fr-FR pt-PT de-DE it-IT nl-NL
es it de fr nl pl uk cs pt he ar ko ja]
config.i18n.fallbacks = [:en]
+15
View File
@@ -0,0 +1,15 @@
# frozen_string_literal: true
priority = %w[application/pdf image/jpeg image/png]
indexes = Marcel::MAGIC.each_with_index.with_object({}) do |((type, _), i), acc|
acc[type] = i if priority.include?(type)
break acc if acc.size == priority.size
end
pdf_index, jpg_index, png_index = indexes.values_at(*priority)
Marcel::MAGIC[0], Marcel::MAGIC[pdf_index] = Marcel::MAGIC[pdf_index], Marcel::MAGIC[0]
Marcel::MAGIC[1], Marcel::MAGIC[jpg_index] = Marcel::MAGIC[jpg_index], Marcel::MAGIC[1]
Marcel::MAGIC[2], Marcel::MAGIC[png_index] = Marcel::MAGIC[png_index], Marcel::MAGIC[2]
-3
View File
@@ -1,3 +0,0 @@
# frozen_string_literal: true
Oj.optimize_rails
+47 -5
View File
@@ -658,6 +658,9 @@ en: &en
create_a_new_template_document_form_or_submit_the_existing_one_html: <a href="%{new_template_link}" data-turbo-frame="modal" class="inline underline font-medium">Create a new template</a> document form or <a href="%{start_form_link}" target="_blank" class="inline underline font-medium">submit the existing one</a>
send_email_copy_with_completed_documents_to_a_specified_bcc_address: Send email copy with completed documents to a specified BCC address.
re_send_email: Re-send Email
re_send_emails: Re-send Emails
are_you_sure_you_want_to_re_send_email_to_n_recipients: Are you sure you want to re-send the invitation email to %{count} recipients?
emails_have_been_sent_to_n_recipients: Emails have been sent to %{count} recipients.
send_email: Send Email
copy_share_link: Copy Share Link
copied_to_clipboard: Copied to Clipboard
@@ -934,6 +937,9 @@ en: &en
your_email_address_has_been_changed: Your email address has been changed
the_email_address_for_your_account_has_been_changed_to_new_email: The email address for your account has been changed to %{new_email}.
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: If you did not make this change, please contact us by replying to this email.
open_file_from: 'Open file from'
cancel: Cancel
open: Open
devise:
confirmations:
confirmed: Your email address has been successfully confirmed.
@@ -1464,7 +1470,7 @@ es: &es
type_emails_here: Escribe correos electrónicos aquí
or_embed_on_your_website: O integra en tu sitio web
phone: Teléfono
send_emails: Enviar correos electrónicos
send_emails: Enviar emails
edit_message: Editar mensaje
smtp_not_configured: SMTP no configurado
configure_smtp_settings_in_order_to_send_emails_: 'Configura los ajustes de SMTP para enviar correos electrónicos:'
@@ -1707,8 +1713,11 @@ es: &es
sign_in_person: Firma en persona
create_a_new_template_document_form_or_submit_the_existing_one_html: <a href="%{new_template_link}" data-turbo-frame="modal" class="inline underline font-medium">Crear una nueva plantilla</a> de documento o <a href="%{start_form_link}" target="_blank" class="inline underline font-medium">enviar el existente</a>
send_email_copy_with_completed_documents_to_a_specified_bcc_address: Enviar una copia del correo electrónico con los documentos completados a una dirección BCC especificada.
re_send_email: Reenviar correo electrónico
send_email: Enviar correo electrónico
re_send_email: Reenviar email
re_send_emails: Reenviar emails
are_you_sure_you_want_to_re_send_email_to_n_recipients: ¿Estás seguro de que quieres reenviar el email de invitación a %{count} destinatarios?
emails_have_been_sent_to_n_recipients: Los emails han sido enviados a %{count} destinatarios.
send_email: Enviar email
copy_share_link: Copiar enlace de compartir
copied_to_clipboard: Copiado al portapapeles
link: Enlace
@@ -1981,6 +1990,9 @@ es: &es
your_email_address_has_been_changed: Tu dirección de correo electrónico ha sido cambiada
the_email_address_for_your_account_has_been_changed_to_new_email: La dirección de correo electrónico de tu cuenta ha sido cambiada a %{new_email}.
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Si no realizaste este cambio, contáctanos respondiendo a este correo electrónico.
open_file_from: 'Abrir archivo desde'
cancel: Cancelar
open: Abrir
devise:
confirmations:
confirmed: Tu dirección de correo electrónico ha sido confirmada correctamente.
@@ -2754,7 +2766,10 @@ it: &it
sign_in_person: Firma di persona
create_a_new_template_document_form_or_submit_the_existing_one_html: <a href="%{new_template_link}" data-turbo-frame="modal" class="inline underline font-medium">Crea un nuovo modello</a> di documento o <a href="%{start_form_link}" target="_blank" class="inline underline font-medium">invia quello esistente</a>
send_email_copy_with_completed_documents_to_a_specified_bcc_address: Invia una copia dell'email con i documenti completati a un indirizzo BCC specificato.
re_send_email: Invia di nuovo l'email
re_send_email: Reinvia email
re_send_emails: Reinvia le email
are_you_sure_you_want_to_re_send_email_to_n_recipients: Sei sicuro di voler inviare di nuovo l'email di invito a %{count} destinatari?
emails_have_been_sent_to_n_recipients: Le email sono state inviate a %{count} destinatari.
send_email: Invia email
copy_share_link: Copia link di condivisione
copied_to_clipboard: Copiato negli appunti
@@ -3028,6 +3043,9 @@ it: &it
your_email_address_has_been_changed: Il tuo indirizzo email è stato modificato
the_email_address_for_your_account_has_been_changed_to_new_email: L'indirizzo email del tuo account è stato modificato in %{new_email}.
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Se non hai effettuato questa modifica, contattaci rispondendo a questa email.
open_file_from: 'Aprire file da'
cancel: Annulla
open: Apri
devise:
confirmations:
confirmed: Il tuo indirizzo email è stato confermato con successo.
@@ -3802,6 +3820,9 @@ fr: &fr
create_a_new_template_document_form_or_submit_the_existing_one_html: <a href="%{new_template_link}" data-turbo-frame="modal" class="inline underline font-medium">Créez un nouveau modèle</a> de document ou <a href="%{start_form_link}" target="_blank" class="inline underline font-medium">soumettez l’existant</a>
send_email_copy_with_completed_documents_to_a_specified_bcc_address: Envoyer une copie e‑mail avec les documents complétés à une adresse Cci spécifiée.
re_send_email: Renvoyer l’e‑mail
re_send_emails: Renvoyer e‑mails
are_you_sure_you_want_to_re_send_email_to_n_recipients: Êtes‑vous sûr de vouloir renvoyer l’e‑mail d’invitation à %{count} destinataires ?
emails_have_been_sent_to_n_recipients: Les e‑mails ont été envoyés à %{count} destinataires.
send_email: Envoyer l’e‑mail
copy_share_link: Copier le lien de partage
copied_to_clipboard: Copié dans le presse‑papiers
@@ -4072,6 +4093,9 @@ fr: &fr
your_email_address_has_been_changed: Votre adresse e-mail a été modifiée
the_email_address_for_your_account_has_been_changed_to_new_email: "L'adresse e-mail de votre compte a été modifiée en %{new_email}."
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Si vous n'avez pas effectué ce changement, veuillez nous contacter en répondant à cet e-mail.
open_file_from: 'Ouvrir le fichier depuis'
cancel: Annuler
open: Ouvrir
devise:
confirmations:
confirmed: Votre adresse e-mail a été confirmée avec succès.
@@ -4321,7 +4345,7 @@ pt: &pt
Obrigado,
{account.name}
view: Visualizar
view: Ver
email: Email
form_expired_at_html: 'O formulário expirou em <span class="font-semibold">%{time}</span>'
verification_code_code: 'Código de verificação: %{code}'
@@ -4846,6 +4870,9 @@ pt: &pt
create_a_new_template_document_form_or_submit_the_existing_one_html: '<a href="%{new_template_link}" data-turbo-frame="modal" class="inline underline font-medium">Criar um novo modelo</a> de documento ou <a href="%{start_form_link}" target="_blank" class="inline underline font-medium">submeter o existente</a>'
send_email_copy_with_completed_documents_to_a_specified_bcc_address: Envie uma cópia do e-mail com documentos concluídos para um endereço BCC especificado.
re_send_email: Reenviar e-mail
re_send_emails: Reenviar e-mails
are_you_sure_you_want_to_re_send_email_to_n_recipients: Tem certeza de que deseja reenviar o e-mail de convite para %{count} destinatários?
emails_have_been_sent_to_n_recipients: Os e-mails foram enviados para %{count} destinatários.
send_email: Enviar e-mail
copy_share_link: Copiar link de compartilhamento
copied_to_clipboard: Copiado para a área de transferência
@@ -5119,6 +5146,9 @@ pt: &pt
your_email_address_has_been_changed: Seu endereço de e-mail foi alterado
the_email_address_for_your_account_has_been_changed_to_new_email: O endereço de e-mail da sua conta foi alterado para %{new_email}.
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Se você não fez essa alteração, entre em contato conosco respondendo a este e-mail.
open_file_from: 'Abrir arquivo de'
cancel: Cancelar
open: Abrir
devise:
confirmations:
confirmed: Seu endereço de e-mail foi confirmado com sucesso.
@@ -5893,6 +5923,9 @@ de: &de
create_a_new_template_document_form_or_submit_the_existing_one_html: <a href="%{new_template_link}" data-turbo-frame="modal" class="inline underline font-medium">Neue Vorlage erstellen</a> oder <a href="%{start_form_link}" target="_blank" class="inline underline font-medium">bestehende einreichen</a>
send_email_copy_with_completed_documents_to_a_specified_bcc_address: Senden Sie eine E-Mail-Kopie mit abgeschlossenen Dokumenten an eine angegebene BCC-Adresse.
re_send_email: E-Mail erneut senden
re_send_emails: E-Mails senden
are_you_sure_you_want_to_re_send_email_to_n_recipients: Sind Sie sicher, dass Sie die Einladungs-E-Mail erneut an %{count} Empfänger senden möchten?
emails_have_been_sent_to_n_recipients: Die E-Mails wurden an %{count} Empfänger gesendet.
send_email: E-Mail senden
copy_share_link: Freigabelink kopieren
copied_to_clipboard: In die Zwischenablage kopiert
@@ -6166,6 +6199,9 @@ de: &de
your_email_address_has_been_changed: Ihre E-Mail-Adresse wurde geändert
the_email_address_for_your_account_has_been_changed_to_new_email: Die E-Mail-Adresse Ihres Kontos wurde in %{new_email} geändert.
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Wenn Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns bitte, indem Sie auf diese E-Mail antworten.
open_file_from: 'Datei öffnen von'
cancel: Abbrechen
open: Öffnen
devise:
confirmations:
confirmed: Ihre E-Mail-Adresse wurde erfolgreich bestätigt.
@@ -7345,6 +7381,9 @@ nl: &nl
create_a_new_template_document_form_or_submit_the_existing_one_html: <a href="%{new_template_link}" data-turbo-frame="modal" class="inline underline font-medium">Maak een nieuw sjabloon</a> voor een documentformulier of <a href="%{start_form_link}" target="_blank" class="inline underline font-medium">dien het bestaande in</a>
send_email_copy_with_completed_documents_to_a_specified_bcc_address: Stuur een e-mailkopie met voltooide documenten naar een opgegeven BCC-adres.
re_send_email: E-mail opnieuw verzenden
re_send_emails: E-mails verzenden
are_you_sure_you_want_to_re_send_email_to_n_recipients: Weet je zeker dat je de uitnodigings-e-mail opnieuw wilt verzenden naar %{count} ontvangers?
emails_have_been_sent_to_n_recipients: De e-mails zijn verzonden naar %{count} ontvangers.
send_email: E-mail verzenden
copy_share_link: Deellink kopiëren
copied_to_clipboard: Gekopieerd naar klembord
@@ -7614,6 +7653,9 @@ nl: &nl
your_email_address_has_been_changed: Uw e-mailadres is gewijzigd
the_email_address_for_your_account_has_been_changed_to_new_email: Het e-mailadres van uw account is gewijzigd naar %{new_email}.
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Als u deze wijziging niet hebt aangebracht, neem dan contact met ons op door op deze e-mail te antwoorden.
open_file_from: 'Bestand openen van'
cancel: Annuleren
open: Openen
devise:
confirmations:
confirmed: Uw e-mailadres is succesvol bevestigd.
+5 -1
View File
@@ -72,6 +72,7 @@ Rails.application.routes.draw do
resources :unarchive, only: %i[create], controller: 'submissions_unarchive'
resources :events, only: %i[index], controller: 'submission_events'
resources :download, only: %i[index], controller: 'submissions_download'
resources :resend_email, only: %i[create], controller: 'submissions_resend_email'
end
resources :submitters, only: %i[edit update]
resources :console_redirect, only: %i[index]
@@ -98,6 +99,9 @@ Rails.application.routes.draw do
resources :clone, only: %i[new create], controller: 'templates_clone'
resource :debug, only: %i[show], controller: 'templates_debug' if Rails.env.development?
resources :documents, only: %i[index create], controller: 'template_documents'
resources :documents_modify, only: %i[create], controller: 'template_documents_modify'
resources :documents_page_objects, only: %i[index], controller: 'template_documents_page_objects'
resources :documents_crop, only: %i[index create], controller: 'template_documents_crop'
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'
@@ -108,7 +112,7 @@ Rails.application.routes.draw do
resource :form, only: %i[show], controller: 'templates_form_preview'
resource :code_modal, only: %i[show], controller: 'templates_code_modal'
resource :preferences, only: %i[show create destroy], controller: 'templates_preferences'
resources :versions, only: %i[index show], controller: 'templates_versions'
resources :versions, only: %i[index show create], controller: 'templates_versions'
resource :share_link, only: %i[show create], controller: 'templates_share_link'
resource :share_link_qr, only: %i[show], controller: 'templates_share_link_qr'
resources :recipients, only: %i[create], controller: 'templates_recipients'
+1 -1
View File
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Accounts
LINK_EXPIRES_AT = 40.minutes
LINK_EXPIRES_AT = ENV.fetch('FILE_URLS_EXPIRE_MINUTES', '40').to_i.minutes
module_function
+3 -6
View File
@@ -10,7 +10,7 @@ module HtmlToPlainText
doc = Nokogiri::HTML.fragment(cleaned)
doc.css('script').each(&:remove)
doc.xpath('.//script').each(&:remove)
result = process_nodes(doc, line_length)
@@ -33,12 +33,9 @@ module HtmlToPlainText
result = +''
node.children.each do |child|
case child
when Nokogiri::XML::Text
if child.text? || child.cdata?
result << child.text
when Nokogiri::XML::Comment
next
when Nokogiri::XML::Element
elsif child.element?
result << process_element(child, line_length)
end
end
+17
View File
@@ -1,8 +1,25 @@
# frozen_string_literal: true
module ImageUtils
ICO_REGEXP = %r{\Aimage/(?:x-icon|vnd\.microsoft\.icon)\z}
BMP_REGEXP = %r{\Aimage/(?:bmp|x-bmp|x-ms-bmp)\z}
module_function
def load_vips(data, content_type: nil, autorot: false)
content_type ||= Marcel::MimeType.for(data)
if ICO_REGEXP.match?(content_type)
LoadIco.call(data)
elsif BMP_REGEXP.match?(content_type)
LoadBmp.call(data)
else
image = Vips::Image.new_from_buffer(data, '')
autorot ? image.autorot : image
end
end
def blank?(image)
stats = image.stats
+488
View File
@@ -0,0 +1,488 @@
# frozen_string_literal: true
module Leptonica
extend FFI::Library
begin
ffi_lib %w[
libleptonica.so.6
liblept.so.5
leptonica
/opt/homebrew/lib/libleptonica.6.dylib
/usr/local/lib/libleptonica.6.dylib
]
rescue LoadError => e
raise "Could not load leptonica library. Make sure it's installed and in your library path. Error: #{e.message}"
end
typedef :pointer, :PIX
typedef :pointer, :PTA
typedef :pointer, :BOX
typedef :pointer, :BOXA
typedef :pointer, :PIXA
L_CLONE = 2
L_SEVERITY_NONE = 6
DETECT_WIDTH = 256
MIN_QUAD_AREA_RATIO = 0.2
MIN_ORIENT_CONF = 8.0
ORIENT_CONF_RATIO = 2.5
LeptonicaError = Class.new(StandardError)
attach_function :setMsgSeverity, [:int], :int
attach_function :pixCreate, %i[int int int], :PIX
attach_function :pixSetSpp, %i[PIX int], :int
attach_function :pixClone, [:PIX], :PIX
attach_function :pixDestroy, [:pointer], :void
attach_function :pixGetWidth, [:PIX], :int
attach_function :pixGetHeight, [:PIX], :int
attach_function :pixGetDepth, [:PIX], :int
attach_function :pixGetWpl, [:PIX], :int
attach_function :pixGetData, [:PIX], :pointer
attach_function :pixConvertTo32, [:PIX], :PIX
attach_function :pixConvertTo8, %i[PIX int], :PIX
attach_function :pixInvert, %i[PIX PIX], :PIX
attach_function :pixScaleToSize, %i[PIX int int], :PIX
attach_function :pixOtsuAdaptiveThreshold, %i[PIX int int int int float pointer pointer], :int
attach_function :pixCloseBrick, %i[PIX PIX int int], :PIX
attach_function :pixOpenBrick, %i[PIX PIX int int], :PIX
attach_function :pixConnComp, %i[PIX pointer int], :BOXA
attach_function :pixCountPixels, %i[PIX pointer pointer], :int
attach_function :boxaGetCount, [:BOXA], :int
attach_function :boxaGetBox, %i[BOXA int int], :BOX
attach_function :boxaDestroy, [:pointer], :void
attach_function :boxCreate, %i[int int int int], :BOX
attach_function :boxDestroy, [:pointer], :void
attach_function :boxGetGeometry, %i[BOX pointer pointer pointer pointer], :int
attach_function :pixaGetPix, %i[PIXA int int], :PIX
attach_function :pixaDestroy, [:pointer], :void
attach_function :pixClipRectangle, %i[PIX BOX pointer], :PIX
attach_function :ptaCreate, [:int], :PTA
attach_function :ptaAddPt, %i[PTA float float], :int
attach_function :ptaDestroy, [:pointer], :void
attach_function :pixProjectivePtaColor, %i[PIX PTA PTA uint], :PIX
attach_function :pixRotateOrth, %i[PIX int], :PIX
attach_function :pixOrientDetect, %i[PIX pointer pointer int int], :int
attach_function :pixFlipLR, %i[PIX PIX], :PIX
attach_function :pixFlipTB, %i[PIX PIX], :PIX
attach_function :pixBackgroundNormSimple, %i[PIX PIX PIX], :PIX
attach_function :pixGammaTRC, %i[PIX PIX float int int], :PIX
attach_function :pixEndianByteSwap, [:PIX], :int
attach_function :dewarpSinglePage, %i[PIX int int int int pointer pointer int], :int
setMsgSeverity(L_SEVERITY_NONE)
module_function
def crop_document(image_data, corners, scan: false, rotate: nil, flip_h: false, flip_v: false)
pix = read_pix(image_data)
begin
pix32 = checked(pixConvertTo32(pix), 'Failed to convert image to 32bpp')
begin
width = pixGetWidth(pix32)
height = pixGetHeight(pix32)
points = corners.map { |point| [point['x'].to_f * width, point['y'].to_f * height] }
out_width, out_height = output_size(points, width, height)
warped = projective_crop(pix32, points, out_width, out_height)
begin
rotate = rotate.nil? ? detect_orientation(warped) : rotate.to_i
result = transform_result(warped, scan:, rotate:, flip_h:, flip_v:)
read_bytes(result)
ensure
destroy_pix(result) if result && !result.equal?(warped)
destroy_pix(warped)
end
ensure
destroy_pix(pix32)
end
ensure
destroy_pix(pix)
end
end
def detect_document_corners(image_data)
pix = read_detect_pix(image_data)
begin
mask = build_page_mask(pix)
return if mask.nil?
begin
corners = mask_corners(mask)
ensure
destroy_pix(mask)
end
return if corners.nil? || quad_area(corners) < MIN_QUAD_AREA_RATIO
corners.map { |x, y| { 'x' => x.round(6), 'y' => y.round(6) } }
ensure
destroy_pix(pix)
end
end
def read_pix(image_data)
build_pix(load_image(image_data))
end
def read_detect_pix(image_data)
image = load_image(image_data)
height = (DETECT_WIDTH * image.height / image.width.to_f).round.clamp(8, DETECT_WIDTH * 4)
image = image.resize(DETECT_WIDTH / image.width.to_f, vscale: height / image.height.to_f)
build_pix(image)
end
def build_pix(image)
pix = checked(pixCreate(image.width, image.height, 32), 'Failed to read image')
pixSetSpp(pix, 3)
pixGetData(pix).put_bytes(0, image.write_to_memory)
raise LeptonicaError, 'Failed to read image' unless pixEndianByteSwap(pix).zero?
pix
end
def load_image(image_data)
image = ImageUtils.load_vips(image_data)
image = image.colourspace(:srgb) if image.interpretation != :srgb
image = image.cast(:uchar) if image.format != :uchar
image = image.bandjoin(255) unless image.has_alpha?
image
end
def projective_crop(pix32, points, out_width, out_height)
ptas = ptaCreate(4)
ptad = ptaCreate(4)
begin
points.each { |x, y| ptaAddPt(ptas, x, y) }
[[0, 0], [out_width, 0], [out_width, out_height], [0, out_height]].each { |x, y| ptaAddPt(ptad, x, y) }
warped = checked(pixProjectivePtaColor(pix32, ptad, ptas, 0xffffff00), 'Failed to warp image')
begin
box = boxCreate(0, 0, out_width, out_height)
begin
checked(pixClipRectangle(warped, box, nil), 'Failed to clip image')
ensure
destroy_box(box)
end
ensure
destroy_pix(warped)
end
ensure
destroy_pta(ptas)
destroy_pta(ptad)
end
end
def transform_result(warped, scan:, rotate:, flip_h:, flip_v:)
steps = []
steps << ->(pix) { whiten(pix) } if scan
steps << ->(pix) { checked(pixFlipLR(nil, pix), 'Failed to flip image') } if flip_h
steps << ->(pix) { checked(pixFlipTB(nil, pix), 'Failed to flip image') } if flip_v
steps << ->(pix) { checked(pixRotateOrth(pix, rotate / 90), 'Failed to rotate image') } unless rotate.zero?
steps << ->(pix) { dewarp(pix) } if scan
steps.reduce(warped) do |current, step|
step.call(current)
ensure
destroy_pix(current) unless current.equal?(warped)
end
end
def output_size(points, width, height)
out_width = (distance(points[0], points[1]) + distance(points[3], points[2])) / 2.0
out_height = (distance(points[0], points[3]) + distance(points[1], points[2])) / 2.0
[out_width.round.clamp(8, width * 2), out_height.round.clamp(8, height * 2)]
end
def detect_orientation(pix32)
gray = pixConvertTo8(pix32, 0)
return 0 if gray.null?
binary_ptr = FFI::MemoryPointer.new(:pointer)
result = pixOtsuAdaptiveThreshold(gray, pixGetWidth(gray), pixGetHeight(gray), 0, 0, 0.1, nil, binary_ptr)
destroy_pix(gray)
binary = binary_ptr.read_pointer
return 0 if result != 0 || binary.null?
upconf_ptr = FFI::MemoryPointer.new(:float)
leftconf_ptr = FFI::MemoryPointer.new(:float)
result = pixOrientDetect(binary, upconf_ptr, leftconf_ptr, 0, 0)
destroy_pix(binary)
return 0 unless result.zero?
orientation_rotation(upconf_ptr.read_float, leftconf_ptr.read_float)
end
def orientation_rotation(upconf, leftconf)
if leftconf >= MIN_ORIENT_CONF && leftconf >= ORIENT_CONF_RATIO * upconf.abs
90
elsif -upconf >= MIN_ORIENT_CONF && -upconf >= ORIENT_CONF_RATIO * leftconf.abs
180
elsif -leftconf >= MIN_ORIENT_CONF && -leftconf >= ORIENT_CONF_RATIO * upconf.abs
270
else
0
end
end
def dewarp(pix)
out_ptr = FFI::MemoryPointer.new(:pointer)
result = dewarpSinglePage(pix, 0, 1, 1, 0, out_ptr, nil, 0)
out = out_ptr.read_pointer
return pixClone(pix) if result != 0 || out.null?
out
end
def whiten(pix)
normalized = checked(pixBackgroundNormSimple(pix, nil, nil), 'Failed to normalize background')
begin
checked(pixGammaTRC(nil, normalized, 1.0, 70, 190), 'Failed to adjust contrast')
ensure
destroy_pix(normalized)
end
end
def read_bytes(pix)
width = pixGetWidth(pix)
height = pixGetHeight(pix)
raise LeptonicaError, 'Failed to read pixels' unless pixEndianByteSwap(pix).zero?
[pixGetData(pix).read_bytes(width * height * 4), width, height]
end
def build_page_mask(pix)
gray = checked(pixConvertTo8(pix, 0), 'Failed to convert image to grayscale')
begin
binary_ptr = FFI::MemoryPointer.new(:pointer)
result = pixOtsuAdaptiveThreshold(gray, pixGetWidth(gray), pixGetHeight(gray), 0, 0, 0.1,
nil, binary_ptr)
return if result != 0 || binary_ptr.read_pointer.null?
binary = binary_ptr.read_pointer
begin
clean_mask(binary)
ensure
destroy_pix(binary)
end
ensure
destroy_pix(gray)
end
end
def clean_mask(binary)
inverted = checked(pixInvert(nil, binary), 'Failed to invert mask')
begin
closed = checked(pixCloseBrick(nil, inverted, 5, 5), 'Failed to close mask')
begin
checked(pixOpenBrick(nil, closed, 3, 3), 'Failed to open mask')
ensure
destroy_pix(closed)
end
ensure
destroy_pix(inverted)
end
end
def mask_corners(mask)
width = pixGetWidth(mask)
height = pixGetHeight(mask)
pixels = read_mask_pixels(mask)
return if pixels.empty?
bounds = largest_component_bounds(mask)
if bounds
box_x, box_y, box_w, box_h = bounds
pixels = pixels.select do |x, y|
x.between?(box_x, box_x + box_w - 1) && y.between?(box_y, box_y + box_h - 1)
end
return if pixels.empty?
end
image_corners = [[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]]
corners = image_corners.map do |corner_x, corner_y|
pixels.min_by { |x, y| (x - corner_x).abs + (y - corner_y).abs }
end
return if corners.uniq.size < 4
corners.map { |x, y| [x / width.to_f, y / height.to_f] }
end
def largest_component_bounds(mask)
pixa_ptr = FFI::MemoryPointer.new(:pointer)
boxa = pixConnComp(mask, pixa_ptr, 8)
return if boxa.null?
pixa = pixa_ptr.read_pointer
begin
geometry = Array.new(4) { FFI::MemoryPointer.new(:int) }
bounds = nil
best_area = 0
boxaGetCount(boxa).times do |index|
box = boxaGetBox(boxa, index, L_CLONE)
next if box.null?
boxGetGeometry(box, *geometry)
box_x, box_y, box_w, box_h = geometry.map(&:read_int)
if box_w * box_h > best_area
best_area = box_w * box_h
bounds = [box_x, box_y, box_w, box_h]
end
destroy_box(box)
end
bounds
ensure
destroy_pixa(pixa)
destroy_boxa(boxa)
end
end
def read_mask_pixels(pix)
width = pixGetWidth(pix)
height = pixGetHeight(pix)
wpl = pixGetWpl(pix)
raise LeptonicaError, 'Failed to read mask' unless pixEndianByteSwap(pix).zero?
data = pixGetData(pix).read_bytes(wpl * 4 * height)
pixEndianByteSwap(pix)
pixels = []
height.times do |y|
row_offset = y * wpl * 4
width.times do |x|
byte = data.getbyte(row_offset + (x / 8))
pixels << [x, y] if byte.anybits?(0x80 >> (x % 8))
end
end
pixels
end
def quad_area(corners)
area = 0.0
corners.each_with_index do |(x1, y1), index|
x2, y2 = corners[(index + 1) % 4]
area += (x1 * y2) - (x2 * y1)
end
(area / 2.0).abs
end
def distance(point_a, point_b)
Math.sqrt(((point_a[0] - point_b[0])**2) + ((point_a[1] - point_b[1])**2))
end
def checked(pix, message)
raise LeptonicaError, message if pix.nil? || pix.null?
pix
end
def destroy_pix(pix)
return if pix.nil? || pix.null?
pix_ptr = FFI::MemoryPointer.new(:pointer)
pix_ptr.write_pointer(pix)
pixDestroy(pix_ptr)
end
def destroy_pta(pta)
return if pta.nil? || pta.null?
pta_ptr = FFI::MemoryPointer.new(:pointer)
pta_ptr.write_pointer(pta)
ptaDestroy(pta_ptr)
end
def destroy_box(box)
return if box.nil? || box.null?
box_ptr = FFI::MemoryPointer.new(:pointer)
box_ptr.write_pointer(box)
boxDestroy(box_ptr)
end
def destroy_boxa(boxa)
return if boxa.nil? || boxa.null?
boxa_ptr = FFI::MemoryPointer.new(:pointer)
boxa_ptr.write_pointer(boxa)
boxaDestroy(boxa_ptr)
end
def destroy_pixa(pixa)
return if pixa.nil? || pixa.null?
pixa_ptr = FFI::MemoryPointer.new(:pointer)
pixa_ptr.write_pointer(pixa)
pixaDestroy(pixa_ptr)
end
end
+1 -1
View File
@@ -39,7 +39,7 @@ module LoadBmp
bands = header_data[:bpp] / 8
end
image = Vips::Image.new_from_memory(final_pixel_data, header_data[:width], header_data[:height], bands, :uchar)
image = Vips::Image.new_from_memory_copy(final_pixel_data, header_data[:width], header_data[:height], bands, :uchar)
image = image.flip(:vertical) if header_data[:orientation] == -1
+5 -3
View File
@@ -43,7 +43,9 @@ module LoadIco
raise ArgumentError, 'Unable to load' unless image_data_bytes && image_data_bytes.bytesize == best_entry[:size]
return Vips::Image.new_from_buffer(image_data_bytes, '') if image_data_bytes.start_with?(PNG_SIGNATURE)
if image_data_bytes.start_with?(PNG_SIGNATURE)
return ImageUtils.load_vips(image_data_bytes, content_type: 'image/png')
end
image = load_image_entry(image_data_bytes, best_entry[:width], best_entry[:height])
@@ -200,13 +202,13 @@ module LoadIco
return nil unless pixel_data_string.bytesize == expected_bytes && expected_bytes.positive?
Vips::Image.new_from_memory(
Vips::Image.new_from_memory_copy(
pixel_data_string,
dib_width,
image_pixel_height,
4,
:uchar
)
).copy(interpretation: :srgb)
end
# rubocop:enable Metrics
end
+4
View File
@@ -75,6 +75,10 @@ module Mcp
return { content: [{ type: 'text', text: 'Template not found' }], isError: true } unless template
if template.archived_at?
return { content: [{ type: 'text', text: 'Template has been archived' }], isError: true }
end
current_ability.authorize!(:create, Submission.new(template:, account_id: current_user.account_id))
return { content: [{ type: 'text', text: 'Template has no fields' }], isError: true } if template.fields.blank?
+4
View File
@@ -25,6 +25,10 @@ module NumberUtils
ApplicationController.helpers.number_to_currency(number, locale:, precision: 2, unit: CURRENCY_SYMBOLS[format])
elsif locale
ApplicationController.helpers.number_with_delimiter(number, locale:)
elsif format == 'percent'
"#{number}%"
elsif format == 'percent_space'
"#{number.to_s.tr('.', ',')} %"
else
number
end
+526 -31
View File
@@ -27,9 +27,12 @@ class Pdfium
typedef :pointer, :FPDF_TEXTPAGE
typedef :pointer, :FPDF_PAGEOBJECT
typedef :pointer, :FPDF_PATHSEGMENT
typedef :pointer, :FPDF_FONT
MAX_SIZE = 32_767
BLANK_TEXT_CODEPOINTS = [0x00, 0x09, 0x0A, 0x0D, 0x20, 0xA0].freeze
FPDF_ANNOT = 0x01
FPDF_LCD_TEXT = 0x02
FPDF_NO_NATIVETEXT = 0x04
@@ -69,6 +72,16 @@ class Pdfium
end
end
ImageNode = Struct.new(:x, :y, :w, :h) do
def endx
@endx ||= x + w
end
def endy
@endy ||= y + h
end
end
# rubocop:disable Naming/ClassAndModuleCamelCase
class FPDF_LIBRARY_CONFIG < FFI::Struct
layout :version, :int,
@@ -101,8 +114,21 @@ class Pdfium
attach_function :FPDFBitmap_GetWidth, [:FPDF_BITMAP], :int
attach_function :FPDFBitmap_GetHeight, [:FPDF_BITMAP], :int
attach_function :FPDFBitmap_GetStride, [:FPDF_BITMAP], :int
attach_function :FPDFBitmap_GetFormat, [:FPDF_BITMAP], :int
attach_function :FPDFBitmap_FillRect, %i[FPDF_BITMAP int int int int ulong], :void
FPDF_BITMAP_GRAY = 1
FPDF_BITMAP_BGR = 2
FPDF_BITMAP_BGRX = 3
FPDF_BITMAP_BGRA = 4
BITMAP_FORMAT_BANDS = {
FPDF_BITMAP_GRAY => [:gray, 1],
FPDF_BITMAP_BGR => [:bgr, 3],
FPDF_BITMAP_BGRX => [:bgrx, 4],
FPDF_BITMAP_BGRA => [:bgra, 4]
}.freeze
attach_function :FPDF_RenderPageBitmap, %i[FPDF_BITMAP FPDF_PAGE int int int int int int], :void
attach_function :FPDFText_LoadPage, [:FPDF_PAGE], :FPDF_TEXTPAGE
@@ -116,6 +142,7 @@ class Pdfium
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
attach_function :FPDFText_GetLooseCharBox, %i[FPDF_TEXTPAGE int pointer], :int
# Page object functions for extracting paths/lines
attach_function :FPDFPage_CountObjects, [:FPDF_PAGE], :int
@@ -131,6 +158,29 @@ class Pdfium
attach_function :FPDFTextObj_GetText, %i[FPDF_PAGEOBJECT FPDF_TEXTPAGE pointer ulong], :ulong
attach_function :FPDFTextObj_GetFontSize, %i[FPDF_PAGEOBJECT pointer], :int
attach_function :FPDFPage_InsertObject, %i[FPDF_PAGE FPDF_PAGEOBJECT], :void
attach_function :FPDFPage_RemoveObject, %i[FPDF_PAGE FPDF_PAGEOBJECT], :int
attach_function :FPDFPage_GenerateContent, [:FPDF_PAGE], :int
attach_function :FPDFPageObj_Destroy, [:FPDF_PAGEOBJECT], :void
attach_function :FPDFText_GetTextObject, %i[FPDF_TEXTPAGE int], :FPDF_PAGEOBJECT
attach_function :FPDFTextObj_GetFont, [:FPDF_PAGEOBJECT], :FPDF_FONT
attach_function :FPDFText_LoadStandardFont, %i[FPDF_DOCUMENT string], :FPDF_FONT
attach_function :FPDFPageObj_CreateTextObj, %i[FPDF_DOCUMENT FPDF_FONT float], :FPDF_PAGEOBJECT
attach_function :FPDFText_SetText, %i[FPDF_PAGEOBJECT pointer], :int
attach_function :FPDFPageObj_GetMatrix, %i[FPDF_PAGEOBJECT pointer], :int
attach_function :FPDFPageObj_SetMatrix, %i[FPDF_PAGEOBJECT pointer], :int
attach_function :FPDFPageObj_CreateNewRect, %i[float float float float], :FPDF_PAGEOBJECT
attach_function :FPDFPageObj_SetFillColor, %i[FPDF_PAGEOBJECT uint uint uint uint], :int
attach_function :FPDFPath_SetDrawMode, %i[FPDF_PAGEOBJECT int int], :int
attach_function :FPDFFormObj_CountObjects, [:FPDF_PAGEOBJECT], :int
attach_function :FPDFFormObj_GetObject, %i[FPDF_PAGEOBJECT ulong], :FPDF_PAGEOBJECT
attach_function :FPDFFormObj_RemoveObject, %i[FPDF_PAGEOBJECT FPDF_PAGEOBJECT], :int
attach_function :FPDFPageObj_Transform, %i[FPDF_PAGEOBJECT double double double double double double], :void
attach_function :FPDFImageObj_GetBitmap, [:FPDF_PAGEOBJECT], :FPDF_BITMAP
attach_function :FPDFImageObj_LoadJpegFileInline, %i[pointer int FPDF_PAGEOBJECT pointer], :int
# Page object types
FPDF_PAGEOBJ_UNKNOWN = 0
FPDF_PAGEOBJ_TEXT = 1
@@ -216,7 +266,6 @@ class Pdfium
attach_function :FPDFPage_SetRotation, %i[FPDF_PAGE int], :void
attach_function :FPDFPage_TransFormWithClip, %i[FPDF_PAGE pointer pointer], :int
attach_function :FPDFPage_TransformAnnots, %i[FPDF_PAGE double double double double double double], :void
attach_function :FPDFPage_GenerateContent, [:FPDF_PAGE], :int
attach_function :FPDFPage_GetMediaBox, %i[FPDF_PAGE pointer pointer pointer pointer], :int
attach_function :FPDFPage_SetMediaBox, %i[FPDF_PAGE float float float float], :void
attach_function :FPDFPage_GetCropBox, %i[FPDF_PAGE pointer pointer pointer pointer], :int
@@ -241,6 +290,12 @@ class Pdfium
layout :version, :int,
:WriteBlock, :pointer
end
class FPDF_FILEACCESS < FFI::Struct
layout :m_FileLen, :ulong,
:m_GetBlock, :pointer,
:m_Param, :pointer
end
# rubocop:enable Naming/ClassAndModuleCamelCase
attach_function :FPDF_SaveAsCopy, %i[FPDF_DOCUMENT pointer ulong], :int
@@ -257,6 +312,12 @@ class Pdfium
define_singleton_method(:FPDF_ImportPages) { |*| raise PdfiumError, 'FPDF_ImportPages is not available' } # rubocop:disable Naming/MethodName
end
begin
attach_function :FPDF_RemoveOrphanObjects, [:FPDF_DOCUMENT], :int
rescue FFI::NotFoundError
define_singleton_method(:FPDF_RemoveOrphanObjects) { |*| -1 } # rubocop:disable Naming/MethodName
end
FPDF_ERR_SUCCESS = 0
FPDF_ERR_UNKNOWN = 1
FPDF_ERR_FILE = 2
@@ -281,6 +342,10 @@ class Pdfium
PDFIUM_ERRORS[code] || "Unknown error code: #{code}"
end
def self.with_instance(instance = nil)
yield instance
end
def self.check_last_error(context_message = 'PDFium operation failed')
error_code = FPDF_GetLastError()
@@ -303,6 +368,7 @@ class Pdfium
@source_buffer = source_buffer
@form_handle = FFI::Pointer::NULL
@form_fill_info_mem = FFI::Pointer::NULL
@presave_hooks = {}
init_form_fill_environment
end
@@ -322,10 +388,10 @@ class Pdfium
@page_count ||= Pdfium.FPDF_GetPageCount(@document_ptr)
end
def import_pages(src_doc)
def import_pages(src_doc, pages: nil, index: nil)
ensure_not_closed!
result = Pdfium.FPDF_ImportPages(@document_ptr, src_doc.document_ptr, nil, page_count)
result = Pdfium.FPDF_ImportPages(@document_ptr, src_doc.document_ptr, pages, index || page_count)
raise PdfiumError, 'Failed to import pages' if result.zero?
@@ -418,6 +484,8 @@ class Pdfium
def save(io, flags: Pdfium::FPDF_NO_INCREMENTAL)
ensure_not_closed!
run_presave_hooks
file_write_mem = FFI::MemoryPointer.new(FPDF_FILEWRITE.size)
file_write_struct = FPDF_FILEWRITE.new(file_write_mem)
@@ -439,6 +507,24 @@ class Pdfium
io
end
def cleanup
ensure_not_closed!
Pdfium.FPDF_RemoveOrphanObjects(@document_ptr)
end
def standard_font
@standard_font ||= Pdfium.FPDFText_LoadStandardFont(@document_ptr, 'Helvetica')
end
def add_presave_hook(key, &block)
@presave_hooks[key] ||= block
end
def run_presave_hooks
@presave_hooks.each_value(&:call)
end
def close
return if closed?
@@ -495,6 +581,16 @@ class Pdfium
@height ||= Pdfium.FPDF_GetPageHeightF(@page_ptr)
end
def rotation
@rotation ||= Pdfium.FPDFPage_GetRotation(@page_ptr)
end
def rotation=(value)
Pdfium.FPDFPage_SetRotation(@page_ptr, value)
@rotation = value
end
def closed?
@closed
end
@@ -583,12 +679,7 @@ class Pdfium
return @text_nodes if char_count.zero?
left_ptr = FFI::MemoryPointer.new(:double)
right_ptr = FFI::MemoryPointer.new(:double)
bottom_ptr = FFI::MemoryPointer.new(:double)
top_ptr = FFI::MemoryPointer.new(:double)
origin_x_ptr = FFI::MemoryPointer.new(:double)
origin_y_ptr = FFI::MemoryPointer.new(:double)
loose_rect_ptr = FFI::MemoryPointer.new(:float, 4)
i = 0
@@ -611,30 +702,16 @@ class Pdfium
char = codepoint.chr(Encoding::UTF_8)
result = Pdfium.FPDFText_GetCharBox(text_page, box_index, left_ptr, right_ptr, bottom_ptr, top_ptr)
next if Pdfium.FPDFText_GetLooseCharBox(text_page, box_index, loose_rect_ptr).zero?
next if result.zero?
loose_left, loose_top, loose_right, loose_bottom = loose_rect_ptr.read_array_of_float(4)
left = left_ptr.read_double
right = right_ptr.read_double
next if loose_right <= loose_left || loose_top <= loose_bottom
Pdfium.FPDFText_GetCharOrigin(text_page, box_index, origin_x_ptr, origin_y_ptr)
origin_y = origin_y_ptr.read_double
origin_x = origin_x_ptr.read_double
font_size = Pdfium.FPDFText_GetFontSize(text_page, box_index)
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 = origin_x / width
y = abs_y / height
node_width = (abs_width + ((abs_x - origin_x).abs * 2)) / width
node_height = abs_height / height
x = loose_left / width
y = (height - loose_top) / height
node_width = (loose_right - loose_left) / width
node_height = (loose_top - loose_bottom) / height
@text_nodes << TextNode.new(char, x, y, node_width, node_height)
ensure
@@ -650,6 +727,378 @@ class Pdfium
Pdfium.FPDFText_ClosePage(text_page) if text_page && !text_page.null?
end
def redact(rects, &image_processor)
ensure_not_closed!
flatten
rotate
rect_bounds = rects.map do |rect|
left = rect['x'].to_f * width
top = height - (rect['y'].to_f * height)
[left, top - (rect['h'].to_f * height), left + (rect['w'].to_f * width), top]
end
unwrap_form_objects(rect_bounds)
remove_redacted_chars(rect_bounds)
redact_image_objects(rect_bounds, &image_processor) if image_processor
draw_redaction_rects(rect_bounds)
raise PdfiumError, 'Failed to generate page content' if Pdfium.FPDFPage_GenerateContent(@page_ptr).zero?
remove_blank_text_objects
@document.add_presave_hook(:cleanup) { @document.cleanup }
reset_text_memoization
nil
end
def remove_blank_text_objects
text_page = Pdfium.FPDFText_LoadPage(@page_ptr)
return if text_page.null?
blanks = []
begin
Pdfium.FPDFPage_CountObjects(@page_ptr).times do |index|
object_ptr = Pdfium.FPDFPage_GetObject(@page_ptr, index)
next if object_ptr.null?
next unless Pdfium.FPDFPageObj_GetType(object_ptr) == Pdfium::FPDF_PAGEOBJ_TEXT
needed_bytes = Pdfium.FPDFTextObj_GetText(object_ptr, text_page, FFI::Pointer::NULL, 0)
next if needed_bytes < 2
buffer = FFI::MemoryPointer.new(:uint8, needed_bytes)
written = Pdfium.FPDFTextObj_GetText(object_ptr, text_page, buffer, needed_bytes)
next if written < 2
content = buffer.read_bytes(written - 2).force_encoding('UTF-16LE').encode('UTF-8')
blanks << object_ptr if content.codepoints.all? { |code| BLANK_TEXT_CODEPOINTS.include?(code) }
end
ensure
Pdfium.FPDFText_ClosePage(text_page)
end
return if blanks.empty?
blanks.each { |object_ptr| remove_page_object(object_ptr) }
Pdfium.FPDFPage_GenerateContent(@page_ptr)
end
def remove_redacted_chars(rect_bounds)
text_page = Pdfium.FPDFText_LoadPage(@page_ptr)
raise PdfiumError, 'Failed to load text page' if text_page.null?
begin
text_objects_chars = collect_text_objects_chars(text_page, rect_bounds)
ensure
Pdfium.FPDFText_ClosePage(text_page)
end
text_objects_chars.each_value do |entry|
next if entry[:chars].none? { |char| char[:redacted] }
rebuild_text_object_survivors(entry) unless entry[:chars].all? { |char| char[:redacted] }
remove_page_object(entry[:ptr])
end
end
def unwrap_form_objects(rect_bounds = nil)
unwrapped = false
matrix_ptr = FFI::MemoryPointer.new(:float, 6)
loop do
form_ptr = find_form_object(rect_bounds)
break if form_ptr.nil?
unwrapped = true
matrix =
if Pdfium.FPDFPageObj_GetMatrix(form_ptr, matrix_ptr).zero?
[1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
else
matrix_ptr.read_array_of_float(6)
end
(Pdfium.FPDFFormObj_CountObjects(form_ptr) - 1).downto(0) do |index|
child_ptr = Pdfium.FPDFFormObj_GetObject(form_ptr, index)
next if child_ptr.null?
raise PdfiumError, 'Failed to unwrap form object' if Pdfium.FPDFFormObj_RemoveObject(form_ptr,
child_ptr).zero?
Pdfium.FPDFPageObj_Transform(child_ptr, *matrix)
Pdfium.FPDFPage_InsertObject(@page_ptr, child_ptr)
end
remove_page_object(form_ptr)
end
Pdfium.FPDFPage_GenerateContent(@page_ptr) if unwrapped
reset_text_memoization if unwrapped
end
def find_form_object(rect_bounds = nil)
bounds_ptrs = Array.new(4) { FFI::MemoryPointer.new(:float) }
Pdfium.FPDFPage_CountObjects(@page_ptr).times do |index|
object_ptr = Pdfium.FPDFPage_GetObject(@page_ptr, index)
next if object_ptr.null?
next unless Pdfium.FPDFPageObj_GetType(object_ptr) == FPDF_PAGEOBJ_FORM
return object_ptr if rect_bounds.nil?
next if Pdfium.FPDFPageObj_GetBounds(object_ptr, *bounds_ptrs).zero?
left, bottom, right, top = bounds_ptrs.map(&:read_float)
intersects = rect_bounds.any? do |rl, rb, rr, rt|
left < rr && right > rl && bottom < rt && top > rb
end
return object_ptr if intersects
end
nil
end
def collect_text_objects_chars(text_page, rect_bounds)
char_count = Pdfium.FPDFText_CountChars(text_page)
left_ptr, right_ptr, bottom_ptr, top_ptr, origin_x_ptr, origin_y_ptr =
Array.new(6) { FFI::MemoryPointer.new(:double) }
text_objects_chars = {}
index = 0
while index < char_count
object_ptr = Pdfium.FPDFText_GetTextObject(text_page, index)
codepoint = Pdfium.FPDFText_GetUnicode(text_page, index)
box_index = index
if codepoint.between?(0xD800, 0xDBFF) && (index + 1 < char_count)
codepoint2 = Pdfium.FPDFText_GetUnicode(text_page, index + 1)
if codepoint2.between?(0xDC00, 0xDFFF)
codepoint = 0x10000 + ((codepoint - 0xD800) << 10) + (codepoint2 - 0xDC00)
index += 1
end
end
index += 1
next if object_ptr.null?
next if Pdfium.FPDFText_GetCharBox(text_page, box_index, left_ptr, right_ptr, bottom_ptr, top_ptr).zero?
center_x = (left_ptr.read_double + right_ptr.read_double) / 2.0
center_y = (bottom_ptr.read_double + top_ptr.read_double) / 2.0
Pdfium.FPDFText_GetCharOrigin(text_page, box_index, origin_x_ptr, origin_y_ptr)
entry = text_objects_chars[object_ptr.address] ||= { ptr: object_ptr, chars: [] }
entry[:chars] << {
codepoint:,
origin_x: origin_x_ptr.read_double,
origin_y: origin_y_ptr.read_double,
redacted: rect_bounds.any? do |left, bottom, right, top|
center_x.between?(left, right) && center_y.between?(bottom, top)
end
}
end
text_objects_chars
end
def rebuild_text_object_survivors(entry)
font_ptr = @document.standard_font
font_size_ptr = FFI::MemoryPointer.new(:float)
font_size = Pdfium.FPDFTextObj_GetFontSize(entry[:ptr], font_size_ptr).zero? ? 12.0 : font_size_ptr.read_float
matrix_ptr = FFI::MemoryPointer.new(:float, 6)
matrix =
if Pdfium.FPDFPageObj_GetMatrix(entry[:ptr], matrix_ptr).zero?
[1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
else
matrix_ptr.read_array_of_float(6)
end
entry[:chars].reject { |char| char[:redacted] }.each do |char|
new_object = Pdfium.FPDFPageObj_CreateTextObj(@document.document_ptr, font_ptr, font_size)
next if new_object.null?
text_data = [char[:codepoint]].pack('U').encode(Encoding::UTF_16LE).b + "\x00\x00".b
text_buffer = FFI::MemoryPointer.new(:char, text_data.bytesize)
text_buffer.put_bytes(0, text_data)
if Pdfium.FPDFText_SetText(new_object, text_buffer).zero?
Pdfium.FPDFPageObj_Destroy(new_object)
next
end
matrix_ptr.write_array_of_float([matrix[0], matrix[1], matrix[2], matrix[3],
char[:origin_x], char[:origin_y]])
Pdfium.FPDFPageObj_SetMatrix(new_object, matrix_ptr)
Pdfium.FPDFPage_InsertObject(@page_ptr, new_object)
end
end
def reset_text_memoization
remove_instance_variable(:@text) if defined?(@text)
@text_nodes = nil
@text_objects = nil
@line_nodes = nil
end
def remove_page_object(object_ptr)
raise PdfiumError, 'Failed to remove page object' if Pdfium.FPDFPage_RemoveObject(@page_ptr, object_ptr).zero?
Pdfium.FPDFPageObj_Destroy(object_ptr)
end
def draw_redaction_rects(rect_bounds)
rect_bounds.each do |left, bottom, right, top|
rect_object = Pdfium.FPDFPageObj_CreateNewRect(left, bottom, right - left, top - bottom)
raise PdfiumError, 'Failed to create redaction rect' if rect_object.null?
Pdfium.FPDFPageObj_SetFillColor(rect_object, 0, 0, 0, 255)
Pdfium.FPDFPath_SetDrawMode(rect_object, 1, 0)
Pdfium.FPDFPage_InsertObject(@page_ptr, rect_object)
end
end
def redact_image_objects(rect_bounds)
bounds_ptrs = Array.new(4) { FFI::MemoryPointer.new(:float) }
matrix_ptr = FFI::MemoryPointer.new(:float, 6)
Pdfium.FPDFPage_CountObjects(@page_ptr).times do |index|
object_ptr = Pdfium.FPDFPage_GetObject(@page_ptr, index)
next if object_ptr.null?
next unless Pdfium.FPDFPageObj_GetType(object_ptr) == FPDF_PAGEOBJ_IMAGE
next if Pdfium.FPDFPageObj_GetBounds(object_ptr, *bounds_ptrs).zero?
obj_left, obj_bottom, obj_right, obj_top = bounds_ptrs.map(&:read_float)
overlapping = rect_bounds.select do |left, bottom, right, top|
obj_left < right && obj_right > left && obj_bottom < top && obj_top > bottom
end
next if overlapping.empty?
raise PdfiumError, 'Failed to get image matrix' if Pdfium.FPDFPageObj_GetMatrix(object_ptr, matrix_ptr).zero?
matrix = matrix_ptr.read_array_of_float(6)
next if ((matrix[0] * matrix[3]) - (matrix[1] * matrix[2])).abs < 1e-9
bitmap = extract_image_bitmap(object_ptr)
pixel_rects = image_pixel_rects(matrix, bitmap[:width], bitmap[:height], overlapping)
next if pixel_rects.empty?
jpeg = yield(bitmap, pixel_rects)
load_image_jpeg(object_ptr, jpeg) if jpeg
end
end
def extract_image_bitmap(object_ptr)
bitmap_ptr = Pdfium.FPDFImageObj_GetBitmap(object_ptr)
raise PdfiumError, 'Failed to get image bitmap' if bitmap_ptr.nil? || bitmap_ptr.null?
format, bands = BITMAP_FORMAT_BANDS[Pdfium.FPDFBitmap_GetFormat(bitmap_ptr)]
raise PdfiumError, 'Unsupported image bitmap format' if format.nil?
image_width = Pdfium.FPDFBitmap_GetWidth(bitmap_ptr)
image_height = Pdfium.FPDFBitmap_GetHeight(bitmap_ptr)
stride = Pdfium.FPDFBitmap_GetStride(bitmap_ptr)
data = Pdfium.FPDFBitmap_GetBuffer(bitmap_ptr).read_bytes(stride * image_height)
row_size = image_width * bands
data = Array.new(image_height) { |row| data.byteslice(row * stride, row_size) }.join if stride != row_size
{ data:, width: image_width, height: image_height, bands:, format: }
ensure
Pdfium.FPDFBitmap_Destroy(bitmap_ptr) if bitmap_ptr && !bitmap_ptr.null?
end
def image_pixel_rects(matrix, image_width, image_height, rect_bounds)
a, b, c, d, e, f = matrix
det = (a * d) - (b * c)
rect_bounds.filter_map do |left, bottom, right, top|
corners = [[left, bottom], [right, bottom], [left, top], [right, top]].map do |x, y|
u = ((d * (x - e)) - (c * (y - f))) / det
v = ((a * (y - f)) - (b * (x - e))) / det
[u * image_width, (1 - v) * image_height]
end
xs = corners.map(&:first)
ys = corners.map(&:last)
next if xs.max <= 0 || xs.min >= image_width || ys.max <= 0 || ys.min >= image_height
px_left = xs.min.floor.clamp(0, image_width - 1)
px_top = ys.min.floor.clamp(0, image_height - 1)
[px_left, px_top,
(xs.max.ceil - px_left).clamp(1, image_width - px_left),
(ys.max.ceil - px_top).clamp(1, image_height - px_top)]
end
end
def load_image_jpeg(object_ptr, jpeg)
get_block = FFI::Function.new(:int, %i[pointer ulong pointer ulong]) do |_param, position, out, size|
out.put_bytes(0, jpeg.byteslice(position, size) || ''.b)
1
end
file_access = Pdfium::FPDF_FILEACCESS.new
file_access[:m_FileLen] = jpeg.bytesize
file_access[:m_GetBlock] = get_block
file_access[:m_Param] = FFI::Pointer::NULL
pages_ptr = FFI::MemoryPointer.new(:pointer, 1)
pages_ptr.write_pointer(@page_ptr)
result = Pdfium.FPDFImageObj_LoadJpegFileInline(pages_ptr, 1, object_ptr, file_access)
raise PdfiumError, 'Failed to load redacted image' if result.zero?
end
def text_objects
return @text_objects if @text_objects
@@ -801,6 +1250,35 @@ class Pdfium
@line_nodes = @line_nodes.sort { |a, b| a.endy == b.endy ? a.x <=> b.x : a.endy <=> b.endy }
end
def image_nodes
ensure_not_closed!
nodes = []
bounds_ptrs = Array.new(4) { FFI::MemoryPointer.new(:float) }
Pdfium.FPDFPage_CountObjects(@page_ptr).times do |index|
object_ptr = Pdfium.FPDFPage_GetObject(@page_ptr, index)
next if object_ptr.null?
next unless Pdfium.FPDFPageObj_GetType(object_ptr) == FPDF_PAGEOBJ_IMAGE
next if Pdfium.FPDFPageObj_GetBounds(object_ptr, *bounds_ptrs).zero?
obj_left, obj_bottom, obj_right, obj_top = bounds_ptrs.map(&:read_float)
left = (obj_left / width).clamp(0, 1)
top = ((height - obj_top) / height).clamp(0, 1)
right = (obj_right / width).clamp(0, 1)
bottom = ((height - obj_bottom) / height).clamp(0, 1)
next if right - left <= 0 || bottom - top <= 0
nodes << ImageNode.new(left, top, right - left, bottom - top)
end
nodes
end
def rotate
ensure_not_closed!
@@ -864,7 +1342,8 @@ class Pdfium
Pdfium.FPDFPage_TransFormWithClip(page_ptr, matrix_ptr, FFI::Pointer::NULL)
Pdfium.FPDFPage_SetRotation(page_ptr, 0)
Pdfium.FPDFPage_GenerateContent(page_ptr)
reload
true
end
@@ -880,9 +1359,25 @@ class Pdfium
raise PdfiumError, "Failed to flatten page #{page_index}"
end
reload if result == Pdfium::FLATTEN_SUCCESS
result
end
def reload
Pdfium.FPDF_ClosePage(@page_ptr)
@page_ptr = Pdfium.FPDF_LoadPage(@document.document_ptr, @page_index)
raise PdfiumError, "Failed to reload page #{page_index}" if @page_ptr.null?
@rotation = nil
@width = nil
@height = nil
reset_text_memoization
end
def close
return if closed?
+2 -1
View File
@@ -1,6 +1,7 @@
# frozen_string_literal: true
require 'puma/plugin'
require 'redis_client'
# rubocop:disable Metrics
Puma::Plugin.create do
@@ -68,7 +69,7 @@ Puma::Plugin.create do
break
rescue RedisClient::CannotConnectError
raise('Unable to connect to redis') if attempt > 10
raise('Unable to connect to redis') if attempt > 30
end
end
end
+3 -1
View File
@@ -39,7 +39,9 @@ module SendWebhookRequest
data: data
}.to_json
req.headers['X-Docuseal-Signature'] = WebhookUrls::Signatures.sign(webhook_url.hmac_secret, body: req.body)
if req.headers['X-Docuseal-Signature'].blank?
req.headers['X-Docuseal-Signature'] = WebhookUrls::Signatures.sign(webhook_url.hmac_secret, body: req.body)
end
req.options.read_timeout = 15
req.options.open_timeout = 8
+4 -2
View File
@@ -162,7 +162,7 @@ module Submissions
return email.downcase.sub(/@gmail?\z/i, '@gmail.com') if email.match?(/@gmail?\z/i)
return email.downcase if email.include?(',') ||
email.match?(/\.(?:gob|om|mm|cm|et|mo|nz|za|ie|ed\.jp)\z/i) ||
email.match?(/\.(?:gob(?:\.\w+)?|om|mm|cm|et|mo|nz|za|ie|ed\.jp)\z/i) ||
email.exclude?('.')
fixed_email = EmailTypo.call(email.delete_prefix('<'))
@@ -175,7 +175,9 @@ module Submissions
return email.downcase if domain == fixed_domain
return email.downcase if fixed_domain.match?(/\Agmail\.(?!com\z)/i)
if DidYouMean::Levenshtein.distance(domain, fixed_domain) > 3
threshold = fixed_domain.start_with?('hotmail.') ? 2 : 3
if DidYouMean::Levenshtein.distance(domain, fixed_domain) > threshold
Rails.logger.info("Skipped email fix #{domain}")
return email.downcase
+21 -6
View File
@@ -129,6 +129,8 @@ module Submissions
with_submitter_timezone = configs.find { |c| c.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true
with_timestamp_seconds = configs.find { |c| c.key == AccountConfig::WITH_TIMESTAMP_SECONDS_KEY }&.value == true
file_links_expire_at = Accounts.link_expires_at(submission.account) if with_file_links
timezone = account.timezone
timezone = last_submitter.timezone || account.timezone if with_submitter_timezone
@@ -345,6 +347,13 @@ module Submissions
field_name = grouped_value_field_names[value].presence || field['title'].presence || field['name'].to_s
field_type = field['type']
if field_type == 'image' &&
submitter.attachments.find { |a| a.uuid == value }.then { |a| !a.image? || a.content_type == 'image/heic' }
field_type = 'file'
end
[
composer.formatted_text_box(
[
@@ -357,13 +366,13 @@ module Submissions
text_align: field_name.to_s.match?(RTL_REGEXP) ? :right : :left,
line_spacing: 1.3, padding: [0, 0, 2, 0]
),
if field['type'].in?(%w[image signature initials stamp kba]) &&
if field_type.in?(%w[image signature initials stamp kba]) &&
(attachment = submitter.attachments.find { |a| a.uuid == value }) &&
attachment.image?
image =
begin
Submissions::GenerateResultAttachments.load_vips_image(attachment).autorot
ImageUtils.load_vips(attachment.download, content_type: attachment.content_type, autorot: true)
rescue Vips::Error
next unless attachment.content_type.starts_with?('image/')
next if attachment.byte_size.zero?
@@ -374,7 +383,13 @@ module Submissions
scale = [600.0 / image.width, 600.0 / image.height].min
resized_image = image.resize([scale, 1].min)
io = StringIO.new(resized_image.write_to_buffer('.png'))
io =
if field['type'] == 'image' && !resized_image.has_alpha?
StringIO.new(resized_image.colourspace(:srgb).write_to_buffer('.jpg', strip: true))
else
StringIO.new(resized_image.write_to_buffer('.png', strip: true))
end
width = field['type'] == 'initials' ? 50 : 200
height = resized_image.height * (width.to_f / resized_image.width)
@@ -386,7 +401,7 @@ module Submissions
composer.image(io, width:, height:, margin: [5, 0, 10, 0])
composer.formatted_text_box([{ text: '' }])
elsif field['type'].in?(%w[file payment image])
elsif field_type.in?(%w[file payment image])
if field['type'] == 'payment'
unit = CURRENCY_SYMBOLS[field['preferences']['currency']] || field['preferences']['currency']
@@ -402,7 +417,7 @@ module Submissions
link =
if with_file_links
ActiveStorage::Blob.proxy_url(attachment.blob)
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: file_links_expire_at)
else
r.submissions_preview_url(submission.slug, **Docuseal.default_url_options)
end
@@ -411,7 +426,7 @@ module Submissions
end,
padding: [0, 0, 10, 0]
)
elsif field['type'] == 'checkbox'
elsif field_type == 'checkbox'
composer.formatted_text_box([{ text: value.to_s.titleize }], padding: [0, 0, 10, 0])
else
if field['type'] == 'date'
@@ -29,6 +29,8 @@ module Submissions
with_signature_id_reason =
configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false
file_links_expire_at = Accounts.link_expires_at(submission.account) if with_file_links
pdfs_index = GenerateResultAttachments.build_pdfs_index(submission, flatten: is_flatten,
incremental: is_rotate_incremental)
@@ -42,7 +44,8 @@ module Submissions
GenerateResultAttachments.fill_submitter_fields(s, submission.account, pdfs_index,
with_signature_id:, is_flatten:, with_headings: index.zero?,
with_submitter_timezone:, with_file_links:,
with_signature_id_reason:, with_timestamp_seconds:)
file_links_expire_at:, with_signature_id_reason:,
with_timestamp_seconds:)
end
template = submission.template
+37 -30
View File
@@ -11,9 +11,6 @@ module Submissions
'Helvetica'
end
ICO_REGEXP = %r{\Aimage/(?:x-icon|vnd\.microsoft\.icon)\z}
BMP_REGEXP = %r{\Aimage/(?:bmp|x-bmp|x-ms-bmp)\z}
FONT_BOLD_NAME = if File.exist?(FONT_BOLD_PATH)
FONT_BOLD_PATH
else
@@ -154,6 +151,8 @@ module Submissions
with_signature_id_reason =
configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false
file_links_expire_at = Accounts.link_expires_at(submitter.account) if with_file_links
pdfs_index = build_pdfs_index(submitter.submission, submitter:, flatten: is_flatten,
incremental: is_rotate_incremental)
@@ -201,12 +200,14 @@ module Submissions
with_submitter_timezone:,
with_file_links:,
with_timestamp_seconds:,
with_signature_id_reason:)
with_signature_id_reason:,
file_links_expire_at:)
end
def fill_submitter_fields(submitter, account, pdfs_index, with_signature_id:, is_flatten:, with_headings: nil,
with_submitter_timezone: false, with_signature_id_reason: true,
with_timestamp_seconds: false, with_file_links: nil)
with_timestamp_seconds: false, with_file_links: nil,
file_links_expire_at: Accounts.link_expires_at(account))
cell_layouters = Hash.new do |hash, valign|
hash[valign] = HexaPDF::Layout::TextLayouter.new(text_valign: valign.to_sym, text_align: :center)
end
@@ -293,8 +294,11 @@ module Submissions
canvas.font(FONT_NAME, size: font_size)
field_type = field['type']
field_type = 'file' if field_type == 'image' &&
!submitter.attachments.find { |a| a.uuid == value }.image?
if field_type == 'image' &&
submitter.attachments.find { |a| a.uuid == value }.then { |a| !a.image? || a.content_type == 'image/heic' }
field_type = 'file'
end
if field_type == 'signature' && field.dig('preferences', 'with_signature_id').in?([true, false])
with_signature_id = field['preferences']['with_signature_id']
@@ -313,7 +317,10 @@ module Submissions
image =
begin
load_vips_image(attachment, attachments_data_cache).autorot
attachments_data_cache[attachment.uuid] ||= attachment.download
ImageUtils.load_vips(attachments_data_cache[attachment.uuid],
content_type: attachment.content_type, autorot: true)
rescue Vips::Error
next unless attachment.content_type.starts_with?('image/')
next if attachment.byte_size.zero?
@@ -358,7 +365,8 @@ module Submissions
image_x = area_x + ((half_width - image_width) / 2.0)
image_y = height - area_y - image_height
io = StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png'))
io =
StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png', strip: true))
canvas.image(io, at: [image_x, image_y], width: image_width, height: image_height)
@@ -425,7 +433,8 @@ module Submissions
scale = [area_w / image.width, image_height / image.height].min
io = StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png'))
io =
StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png', strip: true))
layouter.fit([text], area_w, base_font_size / 0.65)
.draw(canvas, area_x + TEXT_LEFT_MARGIN,
@@ -451,7 +460,10 @@ module Submissions
image =
begin
load_vips_image(attachment, attachments_data_cache).autorot
attachments_data_cache[attachment.uuid] ||= attachment.download
ImageUtils.load_vips(attachments_data_cache[attachment.uuid],
content_type: attachment.content_type, autorot: true)
rescue Vips::Error
next unless attachment.content_type.starts_with?('image/')
next if attachment.byte_size.zero?
@@ -462,7 +474,14 @@ module Submissions
scale = [(area['w'] * width) / image.width,
(area['h'] * height) / image.height].min
io = StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png'))
resized_image = image.resize([scale * 4, 1].select(&:positive?).min)
io =
if field_type == 'image' && !resized_image.has_alpha?
StringIO.new(resized_image.colourspace(:srgb).write_to_buffer('.jpg', strip: true))
else
StringIO.new(resized_image.write_to_buffer('.png', strip: true))
end
canvas.image(
io,
@@ -504,7 +523,7 @@ module Submissions
url =
if with_file_links
ActiveStorage::Blob.proxy_url(attachment.blob)
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: file_links_expire_at)
else
r.submissions_preview_url(submission.slug, **Docuseal.default_url_options)
end
@@ -731,7 +750,7 @@ module Submissions
pdf.trailer.info[:Creator] = info_creator
if Docuseal.pdf_format == 'pdf/a-3b'
pdf.task(:pdfa, level: '3b')
pdfa_listener = pdf.task(:pdfa, level: '3b')
pdf.config['font.map'] = PDFA_FONT_MAP
end
@@ -747,12 +766,14 @@ module Submissions
begin
pdf.sign(io, write_options: { validate: false }, **sign_params)
rescue HexaPDF::Error, NoMethodError => e
rescue HexaPDF::Error, NoMethodError, TypeError => e
Rollbar.error(e) if defined?(Rollbar)
pdf.instance_variable_get(:@listeners)[:complete_objects].delete(pdfa_listener) if pdfa_listener
begin
pdf.sign(io, write_options: { validate: false, incremental: false }, **sign_params)
rescue HexaPDF::Error
rescue HexaPDF::Error, TypeError
pdf.validate(auto_correct: true)
pdf.sign(io, write_options: { validate: false, incremental: false }, **sign_params)
end
@@ -1014,20 +1035,6 @@ module Submissions
[]
end
def load_vips_image(attachment, cache = {})
cache[attachment.uuid] ||= attachment.download
data = cache[attachment.uuid]
if ICO_REGEXP.match?(attachment.content_type)
LoadIco.call(data)
elsif BMP_REGEXP.match?(attachment.content_type)
LoadBmp.call(data)
else
Vips::Image.new_from_buffer(data, '')
end
end
def r
Rails.application.routes.url_helpers
end
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more