Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#45896] Generate PDF document from a work package description #15850

Draft
wants to merge 50 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
8de9de7
refactor(pdf-export): sort module for better reuse
as-op Jun 13, 2024
9370164
Draft: [#45896] Generate PDF document from a work package description
as-op Jun 13, 2024
696ca05
Merge branch 'refs/heads/dev' into feature/45896-generate-pdf-documen…
as-op Jun 27, 2024
a5ccefa
Merge branch 'refs/heads/dev' into feature/45896-generate-pdf-documen…
as-op Jun 27, 2024
b7e8145
Merge branch 'refs/heads/dev' into feature/45896-generate-pdf-documen…
as-op Jun 27, 2024
283735a
obey rubocop
as-op Jun 27, 2024
299327b
add missing styles
as-op Jun 27, 2024
df6252a
Merge branch 'dev' into feature/45896-generate-pdf-document-from-a-wo…
as-op Jul 10, 2024
42aa57d
bump md-to-pdf gem with support for ckeditor page breaks
as-op Jul 10, 2024
af97d1f
use ckeditor with page break support
as-op Jul 10, 2024
089d1d8
Merge branch 'dev' into feature/45896-generate-pdf-document-from-a-wo…
as-op Jul 17, 2024
defc175
fix support for github markdown alerts
as-op Jul 24, 2024
d8c51ca
Merge branch 'dev' into feature/45896-generate-pdf-document-from-a-wo…
as-op Aug 6, 2024
3ec3c35
apply page size
as-op Aug 6, 2024
63e0f07
Merge branch 'refs/heads/dev' into feature/45896-generate-pdf-documen…
as-op Aug 6, 2024
494d095
fix applying page size
as-op Aug 6, 2024
1bad147
obey rubocop
as-op Aug 6, 2024
97d8731
Merge branch 'refs/heads/dev' into feature/45896-generate-pdf-documen…
as-op Aug 14, 2024
dcc93fe
add icon to menu entry
as-op Aug 14, 2024
722511b
obey eslint
as-op Aug 14, 2024
e986878
Merge branch 'dev' into feature/45896-generate-pdf-document-from-a-wo…
as-op Aug 15, 2024
92d5ebc
Merge branch 'refs/heads/dev' into feature/45896-generate-pdf-documen…
as-op Aug 26, 2024
483ec8f
use ckeditor with page break support
as-op Aug 26, 2024
0f34718
Merge branch 'refs/heads/dev' into feature/45896-generate-pdf-documen…
as-op Sep 3, 2024
39d274c
resolve conflict merge
as-op Sep 3, 2024
695d8e1
resolve conflict merge
as-op Sep 3, 2024
fb8b44c
obey rubocop
as-op Sep 3, 2024
5c8b6ce
Merge branch 'dev' into feature/45896-generate-pdf-document-from-a-wo…
as-op Sep 3, 2024
ada244b
Merge branch 'dev' into feature/45896-generate-pdf-document-from-a-wo…
as-op Sep 17, 2024
67d99e6
Merge branch 'dev' into feature/45896-generate-pdf-document-from-a-wo…
as-op Sep 23, 2024
225c40c
MVP: Switch to existing fonts that we use for the other exports
as-op Sep 23, 2024
066f54b
add feature flag generate_pdf_from_work_package; hide the menu entry
as-op Sep 23, 2024
652810d
update ckeditor build: support for page breaks
as-op Sep 23, 2024
c1b2d74
obey rubocop
as-op Sep 23, 2024
739068f
update ckeditor build: support for page breaks
as-op Sep 24, 2024
b0cb856
Merge branch 'dev' into feature/45896-generate-pdf-document-from-a-wo…
as-op Sep 26, 2024
1346c14
Merge branch 'dev' into feature/45896-generate-pdf-document-from-a-wo…
as-op Sep 26, 2024
e72cdc1
Merge branch 'dev' into feature/45896-generate-pdf-document-from-a-wo…
as-op Sep 26, 2024
e1334e0
Merge branch 'dev' into feature/45896-generate-pdf-document-from-a-wo…
as-op Sep 26, 2024
d0a2fbd
solve merge conflict with dev
as-op Sep 26, 2024
50b5f35
Merge branch 'dev' into feature/45896-generate-pdf-document-from-a-wo…
as-op Nov 4, 2024
01c352e
Merge branch 'dev' into feature/45896-generate-pdf-document-from-a-wo…
as-op Nov 12, 2024
4f7504b
Merge branch 'dev' into feature/45896-generate-pdf-document-from-a-wo…
as-op Nov 12, 2024
9514cc0
Merge branch 'dev' into feature/45896-generate-pdf-document-from-a-wo…
as-op Nov 13, 2024
968fda8
pdf generator: use turbo+primer dialog
as-op Nov 13, 2024
cb6514d
Merge branch 'dev' into feature/45896-generate-pdf-document-from-a-wo…
as-op Nov 13, 2024
cc4e60b
obey rubocop
as-op Nov 13, 2024
927904b
remove dedicated export format
as-op Nov 13, 2024
f0402f5
Merge branch 'dev' into feature/45896-generate-pdf-document-from-a-wo…
as-op Nov 14, 2024
098b9f8
add footer/header text inputs; use options in generator
as-op Nov 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<%= render(Primer::Alpha::Dialog.new(
title: "Generate PDF",
id: MODAL_ID,
size: :large
)) do |dialog|
dialog.with_header(variant: :large)
dialog.with_body do
primer_form_with(
url: generate_pdf_work_package_path(work_package),
data: { turbo: false },
id: GENERATE_PDF_FORM_ID,
method: :get
) do |form|
flex_layout do |modal_body|
generate_selects.each_with_index do |entry, index|
modal_body.with_row(mt: index == 0 ? 0 : 3) do
render(Primer::Alpha::Select.new(
name: entry[:name],
label: entry[:label],
caption: entry[:caption],
size: :medium,
input_width: :small,
value: entry[:options].find { |e| e[:default] }[:value])
) do |component|
entry[:options].each do |entry|
component.option(label: entry[:label], value: entry[:value])
end
end
end
end
modal_body.with_row(mt: 3) do
render Primer::Alpha::TextField.new(
name: :header_text_right,
label: 'Header right',
caption: 'Text to be displayed in the right of the header',
visually_hide_label: false,
value: default_header_text_right
)
end
modal_body.with_row(mt: 3) do
render Primer::Alpha::TextField.new(
name: :footer_text_center,
label: 'Footer center',
caption: 'Text to be displayed in the center of the footer',
visually_hide_label: false,
value: default_footer_text_center
)
end
end
end
end
dialog.with_footer do
render(Primer::ButtonComponent.new(data: { "close-dialog-id": MODAL_ID })) { I18n.t(:button_cancel) }
render(Primer::ButtonComponent.new(
scheme: :primary, type: :submit, form: GENERATE_PDF_FORM_ID,
data: { "close-dialog-id": MODAL_ID })) { "Generate" }
end
end %>
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# frozen_string_literal: true

# -- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
# ++
require "text/hyphen"

module WorkPackages
module Exports
module Generate
class ModalDialogComponent < ApplicationComponent
MODAL_ID = "op-work-package-generate-pdf-dialog"
GENERATE_PDF_FORM_ID = "op-work-packages-generate-pdf-dialog-form"
include OpTurbo::Streamable
include OpPrimer::ComponentHelpers

attr_reader :work_package, :params

def initialize(work_package:, params:)
super

@work_package = work_package
@params = params
end

def default_header_text_right
"#{work_package.type} ##{work_package.id}"
end

def default_footer_text_center
work_package.subject
end

def generate_selects
[
{
name: "hyphenation",
label: "Hyphenation",
caption: "Break words between lines to improve text justification and readability.",
options: hyphenation_options
},
{
name: "paper_size",
label: I18n.t("export.dialog.pdf.paper_size.label"),
caption: "The size of the paper to use for the PDF.",
options: paper_size_options
}
]
end

def language_name(locale)
I18n.translate('cldr.language_name', locale: locale)

Check notice on line 76 in app/components/work_packages/exports/generate/modal_dialog_component.rb

View workflow job for this annotation

GitHub Actions / rubocop

[rubocop] app/components/work_packages/exports/generate/modal_dialog_component.rb#L76 <Rails/ShortI18n>

Use `t` instead of `translate`.
Raw output
app/components/work_packages/exports/generate/modal_dialog_component.rb:76:16: C: Rails/ShortI18n: Use `t` instead of `translate`.

Check notice on line 76 in app/components/work_packages/exports/generate/modal_dialog_component.rb

View workflow job for this annotation

GitHub Actions / rubocop

[rubocop] app/components/work_packages/exports/generate/modal_dialog_component.rb#L76 <Style/StringLiterals>

Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping.
Raw output
app/components/work_packages/exports/generate/modal_dialog_component.rb:76:26: C: Style/StringLiterals: Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping.
rescue

Check notice on line 77 in app/components/work_packages/exports/generate/modal_dialog_component.rb

View workflow job for this annotation

GitHub Actions / rubocop

[rubocop] app/components/work_packages/exports/generate/modal_dialog_component.rb#L77 <Style/RescueStandardError>

Avoid rescuing without specifying an error class.
Raw output
app/components/work_packages/exports/generate/modal_dialog_component.rb:77:9: C: Style/RescueStandardError: Avoid rescuing without specifying an error class.
nil # not supported language
end

def hyphenation_options
# This is a list of languages that are supported by the hyphenation library
# https://rubygems.org/gems/text-hyphen
# The labels are the language names in the language itself (NOT to be put I18n)
supported_languages = [
{ value: "ca", label: "Català" },
{ value: "cs", label: "Čeština" },
{ value: "da", label: "Dansk" },
{ value: "de", label: "Deutsch" },
{ value: "en_uk", label: "English (UK)" },
{ value: "en_us", label: "English (USA)" },
{ value: "es", label: "Español" },
{ value: "et", label: "Eesti" },
{ value: "eu", label: "Euskara" },
{ value: "fi", label: "Suomi" },
{ value: "fr", label: "Français" },
{ value: "ga", label: "Gaeilge" },
{ value: "hr", label: "Hrvatski" },
{ value: "hu", label: "Magyar" },
{ value: "ia", label: "Interlingua" },
{ value: "id", label: "Indonesia" },
{ value: "is", label: "Ísland" },
{ value: "it", label: "Italiano" },
{ value: "mn", label: "Монгол" },
{ value: "ms", label: "Melayu" },
{ value: "nl", label: "Nederlands" },
{ value: "no", label: "Norsk" },
{ value: "pl", label: "Polski" },
{ value: "pt", label: "Português" },
{ value: "ru", label: "Русский" },
{ value: "sk", label: "Slovenčina" },
{ value: "sv", label: "Svenska" }
].sort_by { |item| item[:label] }
[{ value: "", label: "Off", default: true }].concat(supported_languages)
end

def paper_size_options
[
{ label: "A4", value: "A4", default: true },
{ label: "A3", value: "A3" },
{ label: "A2", value: "A2" },
{ label: "A1", value: "A1" },
{ label: "A0", value: "A0" },
{ label: "Executive", value: "EXECUTIVE" },
{ label: "Folio", value: "FOLIO" },
{ label: "Letter", value: "LETTER" },
{ label: "Tabloid", value: "TABLOID" }
]
end
end
end
end
end
17 changes: 15 additions & 2 deletions app/controllers/work_packages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,13 @@ class WorkPackagesController < ApplicationController
accept_key_auth :index, :show

before_action :authorize_on_work_package,
:project, only: :show
:project, only: %i[show generate_pdf_dialog generate_pdf]
before_action :load_and_authorize_in_optional_project,
:check_allowed_export,
:protect_from_unauthorized_export, only: %i[index export_dialog]

before_action :authorize, only: :show_conflict_flash_message
authorization_checked! :index, :show, :export_dialog
authorization_checked! :index, :show, :export_dialog, :generate_pdf_dialog, :generate_pdf

before_action :load_and_validate_query, only: :index, unless: -> { request.format.html? }
before_action :load_work_packages, only: :index, if: -> { request.format.atom? }
Expand Down Expand Up @@ -93,6 +93,19 @@ def export_dialog
respond_with_dialog WorkPackages::Exports::ModalDialogComponent.new(query: @query, project: @project, title: params[:title])
end

def generate_pdf_dialog
respond_with_dialog WorkPackages::Exports::Generate::ModalDialogComponent.new(work_package: work_package, params: params)
end

def generate_pdf
exporter = WorkPackage::PDFExport::DocumentGenerator.new(work_package, params)
export = exporter.export!
send_data(export.content, type: export.mime_type, filename: export.title)
rescue ::Exports::ExportError => e
flash[:error] = e.message
redirect_back(fallback_location: work_package_path(work_package))
end

def show_conflict_flash_message
scheme = params[:scheme]&.to_sym || :danger

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

require "mini_magick"

module WorkPackage::PDFExport::Attachments
module WorkPackage::PDFExport::Common::Attachments
def resize_image(file_path)
tmp_file = Tempfile.new(["temp_image", File.extname(file_path)])
@resized_images = [] if @resized_images.nil?
Expand All @@ -51,4 +51,27 @@ def delete_all_resized_images
@resized_images&.each(&:close!)
@resized_images = []
end

def attachment_image_local_file(attachment)
attachment.file.local_file
rescue StandardError => e
Rails.logger.error "Failed to access attachment #{attachment.id} file: #{e}"
nil # return nil as if the id was wrong and the attachment obj has not been found
end

def attachment_image_filepath(work_package, src)
# images are embedded into markup with the api-path as img.src
attachment = attachment_by_api_content_src(work_package, src)
return nil if attachment.nil? || !pdf_embeddable?(attachment.content_type)

local_file = attachment_image_local_file(attachment)
return nil if local_file.nil?

resize_image(local_file.path)
end

def attachment_by_api_content_src(work_package, src)
# find attachment by api-path
work_package.attachments.detect { |a| api_url_helpers.attachment_content(a.id) == src }
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++

module WorkPackage::PDFExport::Common
module WorkPackage::PDFExport::Common::Common
include Redmine::I18n
include ActionView::Helpers::TextHelper
include ActionView::Helpers::NumberHelper
Expand All @@ -36,7 +36,7 @@ module WorkPackage::PDFExport::Common
private

def get_pdf(_language)
::WorkPackage::PDFExport::View.new(current_language)
::WorkPackage::PDFExport::Common::View.new(current_language)
end

def field_value(work_package, attribute)
Expand Down Expand Up @@ -76,26 +76,11 @@ def with_vertical_margin(opts)
pdf.move_down(opts[:bottom_margin]) if opts.key?(:bottom_margin)
end

def write_optional_page_break
space_from_bottom = pdf.y - pdf.bounds.bottom
if space_from_bottom < styles.page_break_threshold
pdf.start_new_page
end
end

def get_column_value(work_package, column_name)
formatter = formatter_for(column_name, :pdf)
formatter.format(work_package)
end

def get_column_value_cell(work_package, column_name)
value = get_column_value(work_package, column_name)
return get_id_column_cell(work_package, value) if column_name == :id
return get_subject_column_cell(work_package, value) if wants_report? && column_name == :subject

escape_tags(value)
end

def get_formatted_value(value, column_name)
return "" if value.nil?

Expand All @@ -108,19 +93,6 @@ def escape_tags(value)
value.to_s.gsub("<", "&lt;").gsub(">", "&gt;")
end

def get_id_column_cell(work_package, value)
href = url_helpers.work_package_url(work_package)
make_link_href_cell(href, value)
end

def get_subject_column_cell(work_package, value)
make_link_anchor(work_package.id, escape_tags(value))
end

def make_link_href_cell(href, caption)
"<color rgb='#{styles.link_color}'><link href='#{href}'>#{caption}</link></color>"
end

def make_link_anchor(anchor, caption)
"<link anchor=\"#{anchor}\">#{caption}</link>"
end
Expand Down Expand Up @@ -306,6 +278,10 @@ def title_datetime
DateTime.now.strftime("%Y-%m-%d_%H-%M")
end

def footer_date
format_time(Time.zone.now)
end

def current_page_nr
pdf.page_number + @page_count - (with_cover? ? 1 : 0)
end
Expand Down
23 changes: 23 additions & 0 deletions app/models/work_package/pdf_export/common/logo.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
module WorkPackage::PDFExport::Common::Logo
def logo_image
image_obj, image_info = pdf.build_image_object(logo_image_filename)
[image_obj, image_info]
end

def logo_image_filename
image_file = custom_logo_image_filename
image_file = Rails.root.join("app/assets/images/logo_openproject.png") if image_file.nil?
image_file
end

def custom_logo_image_filename
return unless CustomStyle.current.present? &&
CustomStyle.current.export_logo.present? && CustomStyle.current.export_logo.local_file.present?

image_file = CustomStyle.current.export_logo.local_file.path
content_type = OpenProject::ContentTypeDetector.new(image_file).detect
return unless pdf_embeddable?(content_type)

image_file
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -26,28 +26,15 @@
# See COPYRIGHT and LICENSE files for more details.
#++

module WorkPackage::PDFExport::MarkdownField
include WorkPackage::PDFExport::Markdown
module WorkPackage::PDFExport::Common::Macro
PREFORMATTED_BLOCKS = %w(pre code).freeze

def write_markdown_field!(work_package, markdown, label)
return if markdown.blank?

write_optional_page_break
with_margin(styles.wp_markdown_label_margins) do
pdf.formatted_text([styles.wp_markdown_label.merge({ text: label })])
end
with_margin(styles.wp_markdown_margins) do
write_markdown! work_package, apply_markdown_field_macros(markdown, work_package)
end
end

private

def apply_markdown_field_macros(markdown, work_package)
apply_macros(markdown, work_package, WorkPackage::Exports::Macros::Attributes)
end

private

def apply_macros(markdown, work_package, formatter)
return markdown unless formatter.applicable?(markdown)

Expand Down
Loading
Loading