diff --git a/app/components/projects/export_list_modal_component.html.erb b/app/components/projects/export_list_modal_component.html.erb
index edc5ddc0761..398d929a8b9 100644
--- a/app/components/projects/export_list_modal_component.html.erb
+++ b/app/components/projects/export_list_modal_component.html.erb
@@ -10,6 +10,8 @@
<% helpers.supported_export_formats.each do |key| %>
<%= link_to projects_path(format: key, **helpers.projects_query_params.except(:page, :per_page)),
+ "data-controller": "job-dialog",
+ "data-job-dialog-close-dialog-id-value": MODAL_ID,
class: "op-export-options--option-link" do %>
<%= helpers.op_icon("icon-big icon-export-#{key}") %>
<%= t("export.format.#{key}") %>
diff --git a/app/models/exports/pdf/common/common.rb b/app/models/exports/pdf/common/common.rb
index e0e9f6c0a43..df3492f221e 100644
--- a/app/models/exports/pdf/common/common.rb
+++ b/app/models/exports/pdf/common/common.rb
@@ -128,7 +128,7 @@ module Exports::PDF::Common::Common
previous_color = pdf.stroke_color
previous_line_width = pdf.line_width
@pdf.stroke do
- pdf.stroke_color = color
+ pdf.stroke_color = color if color
pdf.line_width = height
pdf.horizontal_line left, right, at: top
end
diff --git a/app/models/projects/exports/formatters/active.rb b/app/models/projects/exports/formatters/active.rb
index a14062a8a0d..9bb7236017a 100644
--- a/app/models/projects/exports/formatters/active.rb
+++ b/app/models/projects/exports/formatters/active.rb
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
@@ -31,7 +29,7 @@ module Projects::Exports
module Formatters
class Active < ::Exports::Formatters::Default
def self.apply?(attribute, export_format)
- export_format == :pdf && %i[active].include?(attribute.to_sym)
+ export_format == :pdf && attribute.to_sym == :active
end
##
diff --git a/app/models/projects/exports/formatters/favored.rb b/app/models/projects/exports/formatters/favored.rb
new file mode 100644
index 00000000000..12909e98891
--- /dev/null
+++ b/app/models/projects/exports/formatters/favored.rb
@@ -0,0 +1,44 @@
+# 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.
+#++
+module Projects::Exports
+ module Formatters
+ class Favored < ::Exports::Formatters::Default
+ def self.apply?(attribute, export_format)
+ export_format == :pdf && attribute.to_sym == :favored
+ end
+
+ ##
+ # Takes a project and returns yes/no depending on the favored attribute
+ def format(project, **)
+ project.favored_by?(User.current) ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No)
+ end
+ end
+ end
+end
diff --git a/app/models/projects/exports/formatters/public.rb b/app/models/projects/exports/formatters/public.rb
index 268133312e1..76b49592aed 100644
--- a/app/models/projects/exports/formatters/public.rb
+++ b/app/models/projects/exports/formatters/public.rb
@@ -31,7 +31,7 @@ module Projects::Exports
module Formatters
class Public < ::Exports::Formatters::Default
def self.apply?(attribute, export_format)
- export_format == :pdf && %i[public].include?(attribute.to_sym)
+ export_format == :pdf && attribute.to_sym == :public
end
##
diff --git a/app/models/projects/exports/formatters/required_disk_space.rb b/app/models/projects/exports/formatters/required_disk_space.rb
new file mode 100644
index 00000000000..31d7bb1cee8
--- /dev/null
+++ b/app/models/projects/exports/formatters/required_disk_space.rb
@@ -0,0 +1,46 @@
+# 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.
+#++
+module Projects::Exports
+ module Formatters
+ class RequiredDiskSpace < ::Exports::Formatters::Default
+ def self.apply?(attribute, export_format)
+ export_format == :pdf && attribute.to_sym == :required_disk_space
+ end
+
+ ##
+ # Takes a project and returns the formatted value if the required disk space is greater than 0.
+ def format(project, **)
+ return "" unless project.required_disk_space.to_i > 0
+
+ number_to_human_size(project.required_disk_space, precision: 2)
+ end
+ end
+ end
+end
diff --git a/app/models/projects/exports/pdf.rb b/app/models/projects/exports/pdf.rb
new file mode 100644
index 00000000000..c321cb023ea
--- /dev/null
+++ b/app/models/projects/exports/pdf.rb
@@ -0,0 +1,141 @@
+# 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.
+#++
+
+module Projects::Exports
+ class PDF < QueryExporter
+ include Exports::PDF::Common::Common
+ include Exports::PDF::Common::Logo
+ include Exports::PDF::Common::Attachments
+ include Exports::PDF::Common::Markdown
+ include Exports::PDF::Components::Page
+ include Exports::PDF::Components::WpTable
+ include Exports::PDF::Components::Cover
+ include Projects::Exports::PDFExport::TableOfContent
+ include Projects::Exports::PDFExport::Report
+ include Projects::Exports::PDFExport::InfoMap
+ include Projects::Exports::PDFExport::Styles
+
+ attr_accessor :pdf
+
+ def initialize(object, options = {})
+ super
+ setup_page!
+ end
+
+ def setup_page!
+ self.pdf = get_pdf
+ configure_page_size!(:portrait)
+ pdf.title = heading
+ end
+
+ def export!
+ file = render_pdf(all_projects)
+ success(file)
+ rescue StandardError => e
+ Rails.logger.error "Failed to generate PDF export: #{e.message}:\n#{e.backtrace.join("\n")}"
+ error(I18n.t(:error_pdf_failed_to_export, error: e.message))
+ end
+
+ def title
+ build_pdf_filename([heading].join("_"))
+ end
+
+ def heading
+ query.name || I18n.t(:label_project_plural)
+ end
+
+ def footer_title
+ heading
+ end
+
+ def cover_page_heading
+ heading
+ end
+
+ def cover_page_dates
+ nil
+ end
+
+ def cover_page_subheading
+ User.current&.name
+ end
+
+ def cover_page_title
+ @cover_page_title ||= Setting.app_title
+ end
+
+ def render_pdf(projects, filename: "pdf_export")
+ @page_count = 0
+ @id_project_meta_map, flat_list = build_meta_infos_map(projects)
+ file = render_projects_report_pdf(flat_list, filename)
+ if wants_total_page_nrs?
+ @total_page_nr = @page_count
+ @page_count = 0
+ setup_page! # clear current pdf
+ file = render_projects_report_pdf(flat_list, filename)
+ end
+ file
+ end
+
+ def wants_total_page_nrs?
+ true
+ end
+
+ def with_cover?
+ true
+ end
+
+ def render_projects_report_pdf(flat_list, filename)
+ render_projects_report(flat_list)
+ file = Tempfile.new(filename)
+ pdf.render_file(file.path)
+ @page_count += pdf.page_count
+ delete_all_resized_images
+ file.close
+ file
+ end
+
+ def write_after_pages!
+ write_headers!
+ write_footers!
+ end
+
+ def render_projects_report(flat_list)
+ write_cover_page! if with_cover?
+ if flat_list.size > 1
+ render_toc(flat_list, @id_project_meta_map)
+ elsif !flat_list.empty?
+ @id_project_meta_map[flat_list[0].id][:level_path] = []
+ end
+ render_report(flat_list, @id_project_meta_map)
+ write_after_pages!
+ end
+ end
+end
diff --git a/app/models/projects/exports/pdf_export/info_map.rb b/app/models/projects/exports/pdf_export/info_map.rb
new file mode 100644
index 00000000000..e8abe59b69e
--- /dev/null
+++ b/app/models/projects/exports/pdf_export/info_map.rb
@@ -0,0 +1,80 @@
+# 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.
+#++
+
+module Projects::Exports::PDFExport
+ module InfoMap
+ def build_flat_meta_infos_map(projects)
+ infos_map = {}
+ projects.each_with_index do |project, index|
+ infos_map[project.id] = { level_path: [index + 1], level: 0, children: [], project: }
+ end
+ [infos_map, projects.to_a]
+ end
+
+ def init_meta_infos_map_nodes(projects)
+ infos_map = {}
+ projects.each do |project|
+ infos_map[project.id] = { level_path: [], level: 0, children: [], project: }
+ end
+ infos_map
+ end
+
+ def link_meta_infos_map_nodes(infos_map, projects)
+ projects.reject { |wp| wp.parent_id.nil? }.each do |project|
+ parent = infos_map[project.parent_id]
+ infos_map[project.id][:parent] = parent
+ parent[:children].push(infos_map[project.id]) if parent
+ end
+ infos_map
+ end
+
+ def build_meta_infos_map(projects)
+ # build a quick access map for the hierarchy tree
+ infos_map = init_meta_infos_map_nodes projects
+ # connect parent and children (only wp available in the query)
+ infos_map = link_meta_infos_map_nodes infos_map, projects
+ # recursive travers creating level index path e.g. [1, 2, 1] from root nodes
+ root_nodes = infos_map.values.select { |node| node[:parent].nil? }
+ flat_list = []
+ fill_meta_infos_map_nodes({ children: root_nodes }, [], flat_list)
+ [infos_map, flat_list]
+ end
+
+ def fill_meta_infos_map_nodes(node, level_path, flat_list)
+ node[:level_path] = level_path
+ flat_list.push(node[:project]) unless node[:project].nil?
+ index = 1
+ node[:children].each do |sub|
+ fill_meta_infos_map_nodes(sub, level_path + [index], flat_list)
+ index += 1
+ end
+ end
+ end
+end
diff --git a/app/models/projects/exports/pdf_export/report.rb b/app/models/projects/exports/pdf_export/report.rb
new file mode 100644
index 00000000000..8f6e340258e
--- /dev/null
+++ b/app/models/projects/exports/pdf_export/report.rb
@@ -0,0 +1,309 @@
+# 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.
+#++
+
+module Projects::Exports::PDFExport
+ module Report
+ def render_report(projects, info_map)
+ projects.each do |project|
+ write_optional_page_break
+ write_project_detail(project, info_map[project.id])
+ end
+ end
+
+ def write_project_detail(project, info_map_entry)
+ info_map_entry[:page_number] = current_page_nr
+ with_margin(styles.project_margins) do
+ write_project_title(project, info_map_entry[:level_path])
+ write_project_detail_content(project)
+ write_project_work_packages(project)
+ end
+ end
+
+ def write_project_title(project, level_path)
+ text_style = styles.project_title
+ with_margin(styles.project_title_margins) do
+ link_target_at_current_y(project.id)
+ level_string_width = write_project_level(level_path, text_style)
+ pdf.indent(level_string_width) do
+ pdf.formatted_text([text_style.merge({ text: project.name, link: url_helpers.project_url(project) })])
+ end
+ end
+ end
+
+ def write_project_level(level_path, text_style)
+ return 0 if level_path.empty?
+
+ level_string = "#{level_path.join('.')}. "
+ level_string_width = measure_text_width(level_string, text_style)
+ pdf.float { pdf.formatted_text([text_style.merge({ text: level_string })]) }
+ level_string_width
+ end
+
+ def selects
+ @selects = query
+ .selects
+ .reject { |s| s.is_a?(Queries::Selects::NotExistingSelect) }
+ end
+
+ def write_project_detail_content(project)
+ return if selects.empty?
+
+ entries = []
+ selects.each do |select|
+ entries = process_select(project, select, entries)
+ end
+ write_table_entries(entries) unless entries.empty?
+ end
+
+ def process_select(project, select, entries)
+ if custom_field_select?(select)
+ process_custom_field_select(project, select, entries)
+ elsif project_phase_select?(select)
+ process_project_phase_select(project, select, entries)
+ elsif can_view_attribute?(project, select.attribute)
+ process_attribute_select(project, select, entries)
+ else
+ entries
+ end
+ end
+
+ def process_custom_field_select(project, select, entries)
+ return entries unless custom_field_active_in_project?(project, select.custom_field)
+
+ if select.custom_field.formattable?
+ write_table_entries(entries) unless entries.empty?
+ write_formattable_custom_field(project, select.custom_field)
+ []
+ else
+ entry = table_entry(project, "cf_#{select.custom_field.id}", select.caption)
+ entries.push(entry) if entry
+ entries
+ end
+ end
+
+ def process_project_phase_select(project, select, entries)
+ entry = user_can_view_project_phases?(project) ? table_entry_project_phase(project, select) : nil
+ entries.push(entry) if entry
+ entries
+ end
+
+ def process_attribute_select(project, select, entries)
+ if attribute_formattable?(select.attribute)
+ write_table_entries(entries) unless entries.empty?
+ write_formattable_attribute(project, select.attribute, select.caption)
+ []
+ else
+ entry = table_entry(project, select.attribute, select.caption)
+ entries.push(entry) if entry
+ entries
+ end
+ end
+
+ def project_phase_select?(select)
+ select.is_a?(::Queries::Projects::Selects::ProjectPhase)
+ end
+
+ def table_entry_project_phase(project, select)
+ phase = project.phases.active.find_by(definition: select.project_phase_definition)
+ return nil if phase.nil?
+
+ [
+ { content: select.caption }.merge(styles.project_attributes_table_label_cell),
+ format_phase_value(phase)
+ ]
+ end
+
+ def format_phase_value(phase)
+ start = if phase.start_date.present?
+ format_date(phase.start_date)
+ else
+ I18n.t("js.label_no_start_date")
+ end
+
+ finish = if phase.finish_date.present?
+ format_date(phase.finish_date)
+ else
+ I18n.t("js.label_no_due_date")
+ end
+
+ "#{start} - #{finish}"
+ end
+
+ def table_entry(project, value_name, caption)
+ value = format_attribute(project, value_name, :pdf)
+ return nil if hide_empty_attributes? && value.blank?
+
+ value = make_link_href_cell(url_helpers.project_url(project), value) if value_name == :id
+ [
+ { content: caption }.merge(styles.project_attributes_table_label_cell),
+ value || ""
+ ]
+ end
+
+ def can_view_attribute?(_project, attribute)
+ return false if attribute.nil? || %i[name favored].include?(attribute)
+
+ true
+ end
+
+ def user_can_view_project_phases?(project)
+ User.current.allowed_in_project?(:view_project_phases, project) && project.phases.active.any?
+ end
+
+ def attribute_formattable?(attribute)
+ %i[description status_explanation].include? attribute
+ end
+
+ def custom_field_select?(select)
+ select.is_a?(::Queries::Projects::Selects::CustomField)
+ end
+
+ def custom_field_active_in_project?(project, custom_field)
+ custom_field.is_for_all? ||
+ project.project_custom_field_project_mappings.exists?(custom_field_id: custom_field.id)
+ end
+
+ def write_formattable_attribute(project, attribute, caption)
+ write_project_markdown project.try(attribute), caption
+ end
+
+ def write_formattable_custom_field(project, custom_field)
+ custom_field_value = project.custom_value_for(custom_field.id)
+ write_project_markdown custom_field_value.value, custom_field.name
+ end
+
+ def write_project_markdown(value, caption)
+ return if hide_empty_attributes? && value.blank?
+
+ write_markdown_label(caption)
+ value = Prawn::Text::NBSP if value.blank?
+ with_margin(styles.project_markdown_margins) do
+ write_markdown!(value, styles.project_markdown_styling_yml)
+ end
+ end
+
+ def write_markdown_label(caption)
+ with_margin(styles.project_markdown_label_margins) do
+ pdf.formatted_text([styles.project_markdown_label.merge({ text: caption })])
+ end
+ end
+
+ def write_table_entries(row_entries)
+ return if row_entries.empty?
+
+ rows = if attributes_table_4_column?
+ 0.step(row_entries.length - 1, 2).map do |i|
+ row_entries[i] + (row_entries[i + 1] || ["", ""])
+ end
+ else
+ row_entries
+ end
+
+ pdf.table(
+ rows,
+ column_widths: attributes_table_column_widths,
+ cell_style: styles.project_attributes_table_cell.merge({ inline_format: true })
+ )
+ end
+
+ def attributes_table_4_column?
+ true
+ end
+
+ def hide_empty_attributes?
+ true
+ end
+
+ def attributes_table_column_widths
+ widths = if attributes_table_4_column?
+ # label | value | label | value
+ [1.5, 2.0, 1.5, 2.0]
+ else
+ # label | value
+ [1.0, 3.0]
+ end
+ ratio = pdf.bounds.width / widths.sum
+ widths.map { |w| w * ratio }
+ end
+
+ def write_project_work_packages(project)
+ return if work_package_tables.empty?
+
+ with_margin(styles.wp_tables_margins) do
+ write_project_work_packages_tables(project)
+ end
+ end
+
+ def write_project_work_packages_tables(project)
+ work_package_tables.each do |table|
+ type_id = table[:type_id]
+ column_names = table[:column_names]
+ sort_criteria = table[:sort_criteria] || []
+ caption = table[:caption]
+
+ query = build_work_packages_query(project, type_id, column_names, sort_criteria)
+ write_project_work_packages_table(query, caption) if query
+ end
+ end
+
+ def work_package_tables
+ []
+ end
+
+ def build_work_packages_query(project, type_id, column_names, sort_criteria = [])
+ return nil unless type_id
+
+ query = Query.new(project:)
+ query.filters.clear
+ query.include_subprojects = false
+ query.column_names = column_names
+ query.sort_criteria = sort_criteria
+ query.add_filter("type_id", "=", [type_id])
+ query
+ end
+
+ def get_column_value_cell(work_package, column_name)
+ value = get_value_cell_by_column(work_package, column_name, false)
+ value = make_link_href(url_helpers.work_package_url(work_package), value) if column_name == :subject
+ value
+ end
+
+ def write_project_work_packages_table(query, caption)
+ return unless query
+
+ work_packages = query.results.work_packages
+ return unless work_packages.any?
+
+ write_optional_page_break
+ write_markdown_label(caption)
+ write_work_packages_table!(work_packages, query)
+ end
+ end
+end
diff --git a/app/models/projects/exports/pdf_export/schema.json b/app/models/projects/exports/pdf_export/schema.json
new file mode 100644
index 00000000000..381eb333a10
--- /dev/null
+++ b/app/models/projects/exports/pdf_export/schema.json
@@ -0,0 +1,1946 @@
+{
+ "type": "object",
+ "title": "Projects Report PDF",
+ "description": "This document describes the style settings format for the [PDF Export styling file](https://github.com/opf/openproject/blob/dev/app/models/projects/exports/pdf_export/standard.yml)",
+ "properties": {
+ "page": {
+ "$ref": "#/$defs/page"
+ },
+ "page_logo": {
+ "$ref": "#/$defs/page_logo"
+ },
+ "page_header": {
+ "$ref": "#/$defs/page_header"
+ },
+ "page_footer": {
+ "$ref": "#/$defs/page_footer"
+ },
+ "page_heading": {
+ "$ref": "#/$defs/page_heading"
+ },
+ "toc": {
+ "$ref": "#/$defs/toc"
+ },
+ "cover": {
+ "$ref": "#/$defs/cover"
+ },
+ "project": {
+ "$ref": "#/$defs/project"
+ },
+ "wp_table": {
+ "$ref": "#/$defs/wp_table"
+ }
+ },
+ "required": [],
+ "additionalProperties": false,
+ "$defs": {
+ "cover": {
+ "title": "Cover page",
+ "description": "Styling for the cover page of the PDF report export",
+ "x-example": {
+ "cover": {
+ "header": {},
+ "footer": {},
+ "hero": {}
+ }
+ },
+ "type": "object",
+ "properties": {
+ "header": {
+ "title": "Cover page header",
+ "description": "Styling for the cover page header",
+ "$ref": "#/$defs/cover_header"
+ },
+ "footer": {
+ "title": "Cover page footer",
+ "description": "Styling for the cover page footer",
+ "$ref": "#/$defs/cover_footer"
+ },
+ "hero": {
+ "title": "Cover page hero",
+ "description": "Styling for the hero banner at the bottom at the cover page",
+ "$ref": "#/$defs/cover_hero"
+ }
+ }
+ },
+ "cover_header": {
+ "title": "Cover page header",
+ "description": "Styling for the cover page header of the PDF report export",
+ "x-example": {
+ "header": {
+ "logo_height": 25,
+ "border": {}
+ }
+ },
+ "type": "object",
+ "properties": {
+ "spacing": {
+ "title": "Minimum spacing between logo and page header text",
+ "examples": [
+ 20
+ ],
+ "$ref": "#/$defs/measurement"
+ },
+ "offset": {
+ "title": "Offset position from page top",
+ "examples": [
+ 6.5
+ ],
+ "$ref": "#/$defs/measurement"
+ },
+ "logo_height": {
+ "title": "Height of the logo in the page header",
+ "examples": [
+ 25
+ ],
+ "$ref": "#/$defs/measurement"
+ },
+ "border": {
+ "title": "Cover page header",
+ "description": "Styling for the cover page header",
+ "$ref": "#/$defs/cover_header_border"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ }
+ ]
+ },
+ "cover_hero": {
+ "title": "Cover page hero",
+ "description": "Styling for the hero banner at the bottom at the cover page",
+ "x-example": {
+ "header": {
+ "padding_right": 150,
+ "padding_top": 120,
+ "title": {},
+ "heading": {},
+ "subheading": {}
+ }
+ },
+ "type": "object",
+ "properties": {
+ "padding_right": {
+ "title": "Padding right",
+ "description": "Padding only on the right side of the hero banner",
+ "$ref": "#/$defs/measurement"
+ },
+ "padding_top": {
+ "title": "Padding top",
+ "description": "Padding only on the top side of the hero banner",
+ "$ref": "#/$defs/measurement"
+ },
+ "title": {
+ "title": "The first block in the hero",
+ "type": "object",
+ "x-example": {
+ "title": {
+ "max_height": 30,
+ "spacing": 10,
+ "font": "SpaceMono",
+ "size": 10,
+ "color": "414d5f"
+ }
+ },
+ "properties": {
+ "spacing": {
+ "title": "Minimum spacing between title and heading",
+ "examples": [
+ 10
+ ],
+ "$ref": "#/$defs/measurement"
+ },
+ "max_height": {
+ "title": "Maximum height of the block",
+ "examples": [
+ 30
+ ],
+ "$ref": "#/$defs/measurement"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ }
+ ]
+ },
+ "heading": {
+ "title": "The main block in the hero",
+ "type": "object",
+ "x-example": {
+ "heading": {
+ "spacing": 10,
+ "size": 32,
+ "color": "414d5f",
+ "styles": [
+ "bold"
+ ]
+ }
+ },
+ "properties": {
+ "spacing": {
+ "title": "Minimum spacing between heading and subheading",
+ "examples": [
+ 10
+ ],
+ "$ref": "#/$defs/measurement"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ }
+ ]
+ },
+ "dates": {
+ "title": "The dates block in the hero",
+ "type": "object",
+ "x-example": {
+ "heading": {
+ "spacing": 10,
+ "max_height": 20,
+ "size": 32,
+ "color": "414d5f",
+ "styles": [
+ "bold"
+ ]
+ }
+ },
+ "properties": {
+ "max_height": {
+ "title": "Maximum height of the block",
+ "examples": [
+ 30
+ ],
+ "$ref": "#/$defs/measurement"
+ },
+ "spacing": {
+ "title": "Minimum spacing between dates and subheading",
+ "examples": [
+ 10
+ ],
+ "$ref": "#/$defs/measurement"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ }
+ ]
+ },
+ "subheading": {
+ "title": "The last block in the hero",
+ "type": "object",
+ "x-example": {
+ "subheading": {
+ "max_height": 30,
+ "size": 10,
+ "color": "414d5f",
+ "styles": [
+ "italic"
+ ]
+ }
+ },
+ "properties": {
+ "max_height": {
+ "title": "Maximum height of the block",
+ "examples": [
+ 30
+ ],
+ "$ref": "#/$defs/measurement"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ }
+ ]
+ }
+ }
+ },
+ "cover_footer": {
+ "title": "Cover page footer",
+ "description": "Styling for the cover page footer of the PDF report export",
+ "x-example": {
+ "footer": {
+ "offset": 20,
+ "size": 10,
+ "color": "064e80"
+ }
+ },
+ "type": "object",
+ "properties": {
+ "offset": {
+ "title": "Offset position from page bottom",
+ "examples": [
+ 30
+ ],
+ "$ref": "#/$defs/measurement"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ }
+ ]
+ },
+ "cover_header_border": {
+ "title": "Cover page header border",
+ "description": "Styling for the cover page header border of the PDF report export",
+ "x-example": {
+ "border": {
+ "color": "d3dee3",
+ "height": 1,
+ "offset": 6
+ }
+ },
+ "type": "object",
+ "properties": {
+ "spacing": {
+ "title": "Minimum spacing between logo and page header text",
+ "examples": [
+ 20
+ ],
+ "$ref": "#/$defs/measurement"
+ },
+ "offset": {
+ "title": "Offset position from page top",
+ "examples": [
+ 6
+ ],
+ "$ref": "#/$defs/measurement"
+ },
+ "height": {
+ "title": "Line height of the border",
+ "examples": [
+ 25
+ ],
+ "$ref": "#/$defs/measurement"
+ },
+ "color": {
+ "title": "Line color of the border",
+ "$ref": "#/$defs/color"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ }
+ ]
+ },
+ "toc": {
+ "title": "Table of content",
+ "description": "Styling for the table of content of the PDF report export",
+ "x-example": {
+ "toc": {
+ "subject_indent": 4,
+ "indent_mode": "stairs",
+ "margin_top": 10,
+ "margin_bottom": 20,
+ "item": {
+ "size": 9,
+ "color": "000000",
+ "margin_bottom": 4
+ },
+ "item_level_1": {
+ "size": 10,
+ "styles": [
+ "bold"
+ ],
+ "margin_top": 4,
+ "margin_bottom": 4
+ },
+ "item_level_2": {
+ "size": 10
+ }
+ }
+ },
+ "type": "object",
+ "properties": {
+ "subject_indent": {
+ "title": "Indention width",
+ "description": "Indention width for TOC levels",
+ "$ref": "#/$defs/measurement"
+ },
+ "indent_mode": {
+ "title": "Indention mode",
+ "description": "`flat`= no indention, `stairs` = indent on each level, `third_level` = indent only at 3th level",
+ "type": "string",
+ "enum": [
+ "flat",
+ "stairs",
+ "third_level"
+ ]
+ },
+ "item": {
+ "type": "object",
+ "title": "Table of content item",
+ "description": "Default styling for TOC items on all levels.
use item_level_x` as key for TOC items on level `x`.",
+ "x-example": {
+ "item": {
+ "size": 9,
+ "color": "000000",
+ "margin_bottom": 4
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ },
+ {
+ "$ref": "#/$defs/margin"
+ }
+ ]
+ },
+ "title": {
+ "type": "object",
+ "title": "Table of content title",
+ "description": "Default styling for the TOC title.",
+ "x-example": {
+ "item": {
+ "size": 14,
+ "color": "000000",
+ "margin_bottom": 4
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ },
+ {
+ "$ref": "#/$defs/margin"
+ }
+ ]
+ }
+ },
+ "patternProperties": {
+ "^item_level_\\d+": {
+ "title": "Table of content item level",
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ },
+ {
+ "$ref": "#/$defs/margin"
+ }
+ ]
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/margin"
+ }
+ ]
+ },
+ "project": {
+ "title": "Project",
+ "description": "Styling for a project in the PDF report export",
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "object",
+ "title": "Project title",
+ "description": "Default styling for the project title.",
+ "x-example": {
+ "item": {
+ "size": 14,
+ "color": "000000",
+ "margin_bottom": 4
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ },
+ {
+ "$ref": "#/$defs/margin"
+ }
+ ]
+ },
+ "markdown_label": {
+ "type": "object",
+ "title": "Project attribute markdown label",
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ },
+ {
+ "$ref": "#/$defs/margin"
+ }
+ ]
+ },
+ "markdown_margins": {
+ "type": "object",
+ "title": "Project attribute markdown margins",
+ "allOf": [
+ {
+ "$ref": "#/$defs/margin"
+ }
+ ]
+ },
+ "markdown": {
+ "$ref": "#/$defs/markdown"
+ },
+ "attributes_group": {
+ "type": "object",
+ "title": "Project attributes group label",
+ "description": "Label headline for a project attributes group",
+ "x-example": {
+ "attributes_group": {
+ "size": 12,
+ "styles": [
+ "bold"
+ ],
+ "margin_top": 2,
+ "margin_bottom": 4
+ }
+ },
+ "properties": {
+ "hr": {
+ "type": "object",
+ "title": "Horizontal rule for attributes group rule",
+ "x-example": {
+ "border": {
+ "color": "d3dee3",
+ "height": 1
+ }
+ },
+ "properties": {
+ "height": {
+ "title": "Line height of the horizontal rule",
+ "examples": [
+ 1
+ ],
+ "$ref": "#/$defs/measurement"
+ },
+ "color": {
+ "title": "Line color of the horizontal rule",
+ "$ref": "#/$defs/color"
+ }
+ }
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ },
+ {
+ "$ref": "#/$defs/margin"
+ }
+ ]
+ },
+ "attributes_table": {
+ "type": "object",
+ "title": "Project attributes",
+ "description": "Styling for the project attributes table",
+ "x-example": {
+ "attributes_table": {
+ "margin_bottom": 10,
+ "cell": {
+ "size": 9,
+ "color": "000000",
+ "padding_left": 5,
+ "padding_right": 5,
+ "padding_top": 0,
+ "padding_bottom": 5,
+ "border_color": "4B4B4B",
+ "border_width": 0.25
+ },
+ "cell_label": {
+ "styles": [
+ "bold"
+ ]
+ }
+ }
+ },
+ "properties": {
+ "cell": {
+ "title": "Attribute value table cell",
+ "description": "Styling for a table cell with attribute value",
+ "$ref": "#/$defs/table_cell"
+ },
+ "cell_label": {
+ "title": "Attribute label table cell",
+ "description": "Styling for a table cell with attribute label",
+ "$ref": "#/$defs/table_cell"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/margin"
+ }
+ ]
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/margin"
+ }
+ ]
+ },
+ "wp_table": {
+ "type": "object",
+ "title": "Work package table",
+ "description": "Styling for the related tables (Form configuration)",
+ "x-example": {
+ "overview": {
+ "group_heading": {},
+ "table": {}
+ }
+ },
+ "properties": {
+ "margins": {
+ "type": "object",
+ "title": "Overview tables margins",
+ "description": "Styling for margins before and after the tables",
+ "allOf": [
+ {
+ "$ref": "#/$defs/margin"
+ }
+ ]
+ },
+ "group_heading": {
+ "type": "object",
+ "title": "Overview group heading",
+ "description": "Styling for the table group label",
+ "x-example": {
+ "group_heading": {
+ "size": 11,
+ "styles": [
+ "bold"
+ ],
+ "margin_bottom": 10
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ },
+ {
+ "$ref": "#/$defs/margin"
+ }
+ ]
+ },
+ "table": {
+ "type": "object",
+ "title": "Overview table",
+ "x-example": {
+ "table": {
+ "subject_indent": 0,
+ "margin_bottom": 20,
+ "cell": {
+ "size": 9,
+ "color": "000000",
+ "padding": 5
+ },
+ "cell_header": {
+ "size": 9,
+ "styles": [
+ "bold"
+ ]
+ },
+ "cell_sums": {
+ "size": 8,
+ "styles": [
+ "bold"
+ ]
+ }
+ }
+ },
+ "properties": {
+ "cell": {
+ "title": "Table cell",
+ "description": "Styling for a table value cell",
+ "$ref": "#/$defs/table_cell"
+ },
+ "cell_header": {
+ "title": "Table header cell",
+ "description": "Styling for a table header cell",
+ "$ref": "#/$defs/table_cell"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/margin"
+ }
+ ]
+ }
+ }
+ },
+ "markdown": {
+ "type": "object",
+ "title": "Markdown Styling",
+ "description": "Styling for content of project description and long text custom fields",
+ "x-example": {
+ "markdown": {
+ "font": {},
+ "header": {},
+ "header_1": {},
+ "header_2": {},
+ "header_3": {},
+ "paragraph": {},
+ "unordered_list": {},
+ "unordered_list_point": {},
+ "ordered_list": {},
+ "ordered_list_point": {},
+ "task_list": {},
+ "task_list_point": {},
+ "link": {},
+ "code": {},
+ "blockquote": {},
+ "codeblock": {},
+ "table": {}
+ }
+ },
+ "properties": {
+ "font": {
+ "$ref": "#/$defs/font"
+ },
+ "paragraph": {
+ "title": "Markdown paragraph",
+ "description": "A block of text",
+ "type": "object",
+ "properties": {
+ "align": {
+ "type": "string",
+ "enum": [
+ "left",
+ "center",
+ "right",
+ "justify"
+ ]
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ },
+ {
+ "$ref": "#/$defs/padding"
+ }
+ ],
+ "x-example": {
+ "paragraph": {
+ "align": "justify",
+ "padding_bottom": "2mm"
+ }
+ }
+ },
+ "table": {
+ "type": "object",
+ "title": "Markdown table",
+ "x-example": {
+ "table": {
+ "auto_width": true,
+ "header": {
+ "background_color": "F0F0F0",
+ "no_repeating": true,
+ "size": 12
+ },
+ "cell": {
+ "background_color": "000FFF",
+ "size": 10
+ }
+ }
+ },
+ "properties": {
+ "auto_width": {
+ "title": "Automatic column widths",
+ "description": "Table columns should fit the content, equal spacing of columns if value is `false`",
+ "type": "boolean"
+ },
+ "header": {
+ "$ref": "#/$defs/table_header"
+ },
+ "cell": {
+ "$ref": "#/$defs/table_cell"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/margin"
+ },
+ {
+ "$ref": "#/$defs/border"
+ }
+ ]
+ },
+ "html_table": {
+ "type": "object",
+ "title": "HTML table",
+ "x-example": {
+ "table": {
+ "auto_width": true,
+ "header": {
+ "background_color": "F0F0F0",
+ "no_repeating": true,
+ "size": 12
+ },
+ "cell": {
+ "background_color": "000FFF",
+ "size": 10
+ }
+ }
+ },
+ "properties": {
+ "auto_width": {
+ "title": "Automatic column widths",
+ "description": "Table columns should fit the content, equal spacing of columns if value is `false`",
+ "type": "boolean"
+ },
+ "header": {
+ "$ref": "#/$defs/table_header"
+ },
+ "cell": {
+ "$ref": "#/$defs/table_cell"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/margin"
+ },
+ {
+ "$ref": "#/$defs/border"
+ }
+ ]
+ },
+ "headless_table": {
+ "type": "object",
+ "title": "Markdown headless table",
+ "description": "Tables without or empty header rows can be styled differently.",
+ "x-example": {
+ "headless_table": {
+ "auto_width": true,
+ "cell": {
+ "style": "underline",
+ "background_color": "000FFF"
+ }
+ }
+ },
+ "properties": {
+ "auto_width": {
+ "title": "Automatic column widths",
+ "description": "Table columns should fit the content, equal spacing of columns if value is `false`",
+ "type": "boolean"
+ },
+ "cell": {
+ "$ref": "#/$defs/table_cell"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/margin"
+ },
+ {
+ "$ref": "#/$defs/border"
+ }
+ ]
+ },
+ "code": {
+ "type": "object",
+ "title": "Markdown code",
+ "description": "Styling to denote a word or phrase as code",
+ "x-example": {
+ "code": {
+ "font": "Consolas",
+ "color": "880000"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ }
+ ]
+ },
+ "codeblock": {
+ "type": "object",
+ "title": "Markdown code block",
+ "description": "Styling to denote a paragraph as code",
+ "x-example": {
+ "codeblock": {
+ "background_color": "F5F5F5",
+ "font": "Consolas",
+ "size": 8,
+ "color": "880000",
+ "padding": "3mm",
+ "margin_top": "2mm",
+ "margin_bottom": "2mm"
+ }
+ },
+ "properties": {
+ "background_color": {
+ "$ref": "#/$defs/color"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ },
+ {
+ "$ref": "#/$defs/padding"
+ },
+ {
+ "$ref": "#/$defs/margin"
+ }
+ ]
+ },
+ "link": {
+ "type": "object",
+ "title": "Markdown Link",
+ "description": "Styling a clickable link",
+ "x-example": {
+ "link": {
+ "color": "000088"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ }
+ ]
+ },
+ "image": {
+ "type": "object",
+ "title": "Markdown image",
+ "description": "Styling of images",
+ "x-example": {
+ "image": {
+ "max_width": "50mm",
+ "margin": "2mm",
+ "margin_bottom": "3mm",
+ "align": "center",
+ "caption": {
+ "align": "center",
+ "size": 8
+ }
+ }
+ },
+ "properties": {
+ "max_width": {
+ "$ref": "#/$defs/measurement",
+ "title": "Maximum width of the image"
+ },
+ "align": {
+ "$ref": "#/$defs/alignment"
+ },
+ "caption": {
+ "title": "Image caption",
+ "description": "Styling for the caption below an image",
+ "type": "object",
+ "properties": {
+ "align": {
+ "type": "string",
+ "enum": [
+ "left",
+ "center",
+ "right",
+ "justify"
+ ]
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ },
+ {
+ "$ref": "#/$defs/padding"
+ }
+ ]
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/margin"
+ }
+ ]
+ },
+ "hrule": {
+ "type": "object",
+ "title": "Markdown horizontal rule",
+ "description": "Styling for horizontal lines",
+ "properties": {
+ "line_width": {
+ "title": "Sets the stroke width of the horizontal rule",
+ "$ref": "#/$defs/measurement"
+ }
+ },
+ "x-example": {
+ "hrule": {
+ "line_width": 1
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/margin"
+ }
+ ]
+ },
+ "header": {
+ "title": "Markdown header",
+ "description": "Default styling for headers on all levels.
use header_`x` as key for header level `x`.",
+ "$ref": "#/$defs/header"
+ },
+ "blockquote": {
+ "type": "object",
+ "title": "Markdown blockquote",
+ "description": "Styling to denote a paragraph as quote",
+ "x-example": {
+ "blockquote": {
+ "background_color": "f4f9ff",
+ "size": 14,
+ "styles": [
+ "italic"
+ ],
+ "color": "0f3b66",
+ "border_color": "b8d6f4",
+ "border_width": 1,
+ "no_border_right": true,
+ "no_border_left": false,
+ "no_border_bottom": true,
+ "no_border_top": true
+ }
+ },
+ "properties": {
+ "background_color": {
+ "$ref": "#/$defs/color"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ },
+ {
+ "$ref": "#/$defs/border"
+ },
+ {
+ "$ref": "#/$defs/padding"
+ },
+ {
+ "$ref": "#/$defs/margin"
+ }
+ ]
+ },
+ "ordered_list": {
+ "title": "Markdown ordered list",
+ "description": "Default styling for ordered lists on all levels.
use ordered_list_`x` as key for ordered list level `x`.",
+ "$ref": "#/$defs/ordered_list"
+ },
+ "ordered_list_point": {
+ "title": "Markdown ordered list point",
+ "description": "Default styling for ordered list points on all levels.
use ordered_list_point_`x` as key for ordered list points level `x`.",
+ "$ref": "#/$defs/ordered_list_point"
+ },
+ "unordered_list": {
+ "title": "Markdown unordered list",
+ "description": "Default styling for unordered lists on all levels.
use unordered_list_`x` as key for unordered list level `x`.",
+ "$ref": "#/$defs/unordered_list"
+ },
+ "unordered_list_point": {
+ "title": "Markdown unordered list point",
+ "description": "Default styling for unordered list points on all levels.
use unordered_list_point_`x` as key for unordered list points level `x`.",
+ "$ref": "#/$defs/unordered_list_point"
+ },
+ "task_list": {
+ "title": "Markdown task list",
+ "x-example": {
+ "task_list": {
+ "spacing": "2mm"
+ }
+ },
+ "$ref": "#/$defs/unordered_list"
+ },
+ "task_list_point": {
+ "title": "Markdown task list point",
+ "$ref": "#/$defs/task_list_point"
+ },
+ "alerts": {
+ "$ref": "#/$defs/alerts"
+ }
+ },
+ "patternProperties": {
+ "^ordered_list_point_\\d+": {
+ "title": "Markdown ordered list point level",
+ "$ref": "#/$defs/ordered_list_point"
+ },
+ "^ordered_list_\\d+": {
+ "title": "Markdown ordered list level",
+ "$ref": "#/$defs/ordered_list"
+ },
+ "^unordered_list_point_\\d+": {
+ "title": "Markdown unordered list point level",
+ "$ref": "#/$defs/unordered_list_point"
+ },
+ "^unordered_list_\\d+": {
+ "title": "Markdown unordered List Level",
+ "$ref": "#/$defs/unordered_list"
+ },
+ "^header_\\d+": {
+ "title": "Markdown header level",
+ "$ref": "#/$defs/header"
+ }
+ }
+ },
+ "color": {
+ "type": "string",
+ "title": "Color",
+ "description": "A color in RRGGBB format",
+ "examples": [
+ "F0F0F0"
+ ],
+ "x-example": {
+ "color": "F0F0F0"
+ },
+ "pattern": "^(?:[0-9a-fA-F]{3}){1,2}$"
+ },
+ "font": {
+ "title": "Font properties",
+ "description": "Properties to set the font style",
+ "type": "object",
+ "x-example": {
+ "font": "OpenSans",
+ "size": 10,
+ "character_spacing": 0,
+ "styles": [],
+ "color": "000000",
+ "leading": 2
+ },
+ "properties": {
+ "font": {
+ "type": "string"
+ },
+ "size": {
+ "$ref": "#/$defs/measurement"
+ },
+ "character_spacing": {
+ "$ref": "#/$defs/measurement"
+ },
+ "leading": {
+ "$ref": "#/$defs/measurement"
+ },
+ "color": {
+ "$ref": "#/$defs/color"
+ },
+ "styles": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/font_style"
+ }
+ }
+ }
+ },
+ "font_style": {
+ "type": "string",
+ "title": "Font Style",
+ "description": "Style of the font to use",
+ "examples": [
+ "bold"
+ ],
+ "enum": [
+ "bold",
+ "italic",
+ "underline",
+ "strikethrough",
+ "superscript",
+ "subscript"
+ ]
+ },
+ "page": {
+ "title": "Page settings",
+ "description": "Properties to set the basic page settings",
+ "type": "object",
+ "x-example": {
+ "page": {
+ "page_size": "EXECUTIVE",
+ "margin_top": 60,
+ "margin_bottom": 60,
+ "margin_left": 36,
+ "margin_right": 36,
+ "page_break_threshold": 200,
+ "link_color": "175A8E"
+ }
+ },
+ "properties": {
+ "link_color": {
+ "title": "Link color",
+ "description": "Set the color of clickable links",
+ "$ref": "#/$defs/color"
+ },
+ "page_layout": {
+ "title": "Page layout",
+ "description": "The layout of a page",
+ "type": "string",
+ "examples": [
+ "portrait"
+ ],
+ "enum": [
+ "portrait",
+ "landscape"
+ ]
+ },
+ "page_size": {
+ "type": "string",
+ "title": "Page size",
+ "description": "The size of a page",
+ "examples": [
+ "EXECUTIVE"
+ ],
+ "enum": [
+ "EXECUTIVE",
+ "TABLOID",
+ "LETTER",
+ "LEGAL",
+ "FOLIO",
+ "A0",
+ "A1",
+ "A2",
+ "A3",
+ "A4",
+ "A5",
+ "A6",
+ "A7",
+ "A8",
+ "A9",
+ "A10",
+ "B0",
+ "B1",
+ "B2",
+ "B3",
+ "B4",
+ "B5",
+ "B6",
+ "B7",
+ "B8",
+ "B9",
+ "B10",
+ "C0",
+ "C1",
+ "C2",
+ "C3",
+ "C4",
+ "C5",
+ "C6",
+ "C7",
+ "C8",
+ "C9",
+ "C10",
+ "RA0",
+ "RA1",
+ "RA2",
+ "RA3",
+ "RA4",
+ "SRA0",
+ "SRA1",
+ "SRA2",
+ "SRA3",
+ "SRA4",
+ "4A0",
+ "2A0"
+ ]
+ },
+ "page_break_threshold": {
+ "title": "Page break threshold",
+ "description": "If there is a new section, start a new page if space less than the threshold is available",
+ "$ref": "#/$defs/measurement"
+ }
+ },
+ "allOf": [
+ {
+ "title": "Default font settings",
+ "$ref": "#/$defs/font"
+ },
+ {
+ "title": "Page margins",
+ "$ref": "#/$defs/margin"
+ }
+ ]
+ },
+ "page_heading": {
+ "title": "Page heading",
+ "description": "The main page title heading",
+ "x-example": {
+ "page_heading": {
+ "size": 14,
+ "styles": [
+ "bold"
+ ],
+ "margin_bottom": 10
+ }
+ },
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ },
+ {
+ "$ref": "#/$defs/margin"
+ }
+ ]
+ },
+ "page_footer": {
+ "type": "object",
+ "title": "Page footers",
+ "x-example": {
+ "page_footer": {
+ "offset": -30,
+ "size": 8
+ }
+ },
+ "properties": {
+ "offset": {
+ "title": "Offset position from page bottom",
+ "examples": [
+ -30
+ ],
+ "$ref": "#/$defs/measurement_signed"
+ },
+ "spacing": {
+ "title": "Minimum spacing between different page footers",
+ "examples": [
+ 8
+ ],
+ "$ref": "#/$defs/measurement"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ }
+ ]
+ },
+ "page_header": {
+ "type": "object",
+ "title": "Page headers",
+ "x-example": {
+ "page_header": {
+ "align": "left",
+ "offset": 20,
+ "size": 8
+ }
+ },
+ "properties": {
+ "align": {
+ "$ref": "#/$defs/alignment"
+ },
+ "offset": {
+ "title": "Offset position from page top",
+ "examples": [
+ -30
+ ],
+ "$ref": "#/$defs/measurement_signed"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ }
+ ]
+ },
+ "page_logo": {
+ "title": "Page logo",
+ "description": "Styling for logo image in the page header.",
+ "type": "object",
+ "x-example": {
+ "page_logo": {
+ "height": 20,
+ "align": "right"
+ }
+ },
+ "properties": {
+ "height": {
+ "$ref": "#/$defs/measurement",
+ "title": "Height of the image"
+ },
+ "align": {
+ "$ref": "#/$defs/alignment"
+ },
+ "offset": {
+ "title": "Offset position from page top",
+ "examples": [
+ -30
+ ],
+ "$ref": "#/$defs/measurement_signed"
+ }
+ }
+ },
+ "margin": {
+ "title": "Margin properties",
+ "description": "Properties to set margins",
+ "type": "object",
+ "x-example": {
+ "margin": "10mm",
+ "margin_top": "15mm"
+ },
+ "properties": {
+ "margin": {
+ "title": "Margin",
+ "description": "One value for margin on all sides",
+ "$ref": "#/$defs/measurement"
+ },
+ "margin_left": {
+ "title": "Margin left",
+ "description": "Margin only on the left side",
+ "$ref": "#/$defs/measurement"
+ },
+ "margin_right": {
+ "title": "Margin right",
+ "description": "Margin only on the right side",
+ "$ref": "#/$defs/measurement"
+ },
+ "margin_top": {
+ "title": "Margin top",
+ "description": "Margin only on the top side",
+ "$ref": "#/$defs/measurement"
+ },
+ "margin_bottom": {
+ "title": "Margin bottom",
+ "description": "Margin only on the bottom side",
+ "$ref": "#/$defs/measurement"
+ }
+ }
+ },
+ "padding": {
+ "title": "Padding Properties",
+ "description": "Properties to set paddings",
+ "type": "object",
+ "x-example": {
+ "padding": "10mm",
+ "padding_top": "15mm"
+ },
+ "properties": {
+ "padding": {
+ "title": "Padding",
+ "description": "One value for padding on all sides",
+ "$ref": "#/$defs/measurement"
+ },
+ "padding_left": {
+ "title": "Padding left",
+ "description": "Padding only on the left side",
+ "$ref": "#/$defs/measurement"
+ },
+ "padding_right": {
+ "title": "Padding right",
+ "description": "Padding only on the right side",
+ "$ref": "#/$defs/measurement"
+ },
+ "padding_top": {
+ "title": "Padding top",
+ "description": "Padding only on the top side",
+ "$ref": "#/$defs/measurement"
+ },
+ "padding_bottom": {
+ "title": "Padding bottom",
+ "description": "Padding only on the bottom side",
+ "$ref": "#/$defs/measurement"
+ }
+ }
+ },
+ "measurement": {
+ "type": [
+ "number",
+ "string"
+ ],
+ "pattern": "^([0-9\\.]+)(mm|cm|dm|m|in|ft|yr|pt)$",
+ "description": "A number >= 0 and an optional unit",
+ "examples": [
+ "10mm",
+ "10"
+ ]
+ },
+ "measurement_signed": {
+ "type": [
+ "number",
+ "string"
+ ],
+ "pattern": "^-?([0-9\\.]+)(mm|cm|dm|m|in|ft|yr|pt)$",
+ "description": "A positive or negative number and an optional unit"
+ },
+ "alignment": {
+ "title": "Alignment",
+ "description": "How the element should be aligned",
+ "examples": [
+ "center"
+ ],
+ "type": "string",
+ "enum": [
+ "left",
+ "center",
+ "right"
+ ]
+ },
+ "unordered_list": {
+ "title": "Markdown unordered list",
+ "x-example": {
+ "unordered_list": {
+ "spacing": "1.5mm",
+ "padding_top": "2mm",
+ "padding_bottom": "2mm"
+ }
+ },
+ "type": "object",
+ "properties": {
+ "spacing": {
+ "title": "Spacing",
+ "description": "Additional space between list items",
+ "$ref": "#/$defs/measurement"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ },
+ {
+ "$ref": "#/$defs/padding"
+ }
+ ]
+ },
+ "unordered_list_point": {
+ "title": "Markdown unordered list point",
+ "x-example": {
+ "unordered_list_point": {
+ "sign": "•",
+ "spacing": "0.75mm"
+ }
+ },
+ "type": "object",
+ "properties": {
+ "sign": {
+ "title": "Sign",
+ "description": "The 'bullet point' character used in the list",
+ "type": "string"
+ },
+ "spacing": {
+ "title": "Spacing",
+ "description": "Space between point and list item content",
+ "$ref": "#/$defs/measurement"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ }
+ ]
+ },
+ "task_list_point": {
+ "type": "object",
+ "title": "Markdown task list point",
+ "x-example": {
+ "task_list_point": {
+ "checked": "☑",
+ "unchecked": "☐",
+ "spacing": "0.75mm"
+ }
+ },
+ "properties": {
+ "checked": {
+ "title": "Checked sign",
+ "description": "Sign for checked state of a task list item",
+ "type": "string"
+ },
+ "unchecked": {
+ "title": "Unchecked sign",
+ "description": "Sign for unchecked state of a task list item",
+ "type": "string"
+ },
+ "spacing": {
+ "title": "Spacing",
+ "description": "Additional space between point and list item content",
+ "$ref": "#/$defs/measurement"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ }
+ ]
+ },
+ "ordered_list": {
+ "title": "Markdown ordered list",
+ "x-example": {
+ "ordered_list": {
+ "spacing": "2mm",
+ "point_inline": false
+ }
+ },
+ "type": "object",
+ "properties": {
+ "spacing": {
+ "title": "Spacing",
+ "description": "Additional space between list items",
+ "$ref": "#/$defs/measurement"
+ },
+ "point_inline": {
+ "title": "Inline Point",
+ "description": "Do not indent paragraph text, but include the point into the first paragraph",
+ "type": "boolean"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ },
+ {
+ "$ref": "#/$defs/padding"
+ }
+ ]
+ },
+ "ordered_list_point": {
+ "title": "Markdown ordered list point",
+ "type": "object",
+ "x-example": {
+ "ordered_list_point": {
+ "template": ".",
+ "list_style_type": "decimal",
+ "spacing": "0.75mm",
+ "spanning": true
+ }
+ },
+ "properties": {
+ "spacing": {
+ "$ref": "#/$defs/measurement"
+ },
+ "alphabetical": {
+ "title": "Alphabetical bullet points",
+ "description": "(deprecated; use list_style_type) Convert the list item number into a character, eg. `a.` `b.` `c.`",
+ "type": "boolean"
+ },
+ "list_style_type": {
+ "title": "List style type",
+ "description": "The style of the list bullet points, eg. `decimal`, `lower-latin`, `upper-roman`",
+ "type": "string",
+ "enum": [
+ "decimal",
+ "lower-latin",
+ "lower-roman",
+ "upper-latin",
+ "upper-roman"
+ ]
+ },
+ "spanning": {
+ "title": "Spanning",
+ "description": "Use the width of the largest bullet as indention.",
+ "type": "boolean"
+ },
+ "template": {
+ "title": "Template",
+ "description": "customize what the prefix should contain, eg. `()`",
+ "type": "string"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ }
+ ]
+ },
+ "header": {
+ "type": "object",
+ "title": "Markdown header",
+ "x-example": {
+ "header": {
+ "styles": [
+ "bold"
+ ],
+ "padding_top": "2mm",
+ "padding_bottom": "2mm"
+ },
+ "header_1": {
+ "size": 14,
+ "styles": [
+ "bold",
+ "italic"
+ ]
+ },
+ "header_2": {
+ "size": 12,
+ "styles": [
+ "bold"
+ ]
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ },
+ {
+ "$ref": "#/$defs/padding"
+ }
+ ]
+ },
+ "table_cell": {
+ "type": "object",
+ "title": "Table cell",
+ "description": "Styling for a table cell",
+ "x-example": {
+ "table_cell": {
+ "size": 9,
+ "color": "000000",
+ "padding": 5,
+ "border_width": 1
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ },
+ {
+ "$ref": "#/$defs/padding"
+ },
+ {
+ "$ref": "#/$defs/border"
+ },
+ {
+ "$ref": "#/$defs/cell_alignment"
+ }
+ ]
+ },
+ "table_header": {
+ "type": "object",
+ "title": "Table header cell",
+ "description": "Styling for a table header cell",
+ "x-example": {
+ "table_header": {
+ "size": 9,
+ "styles": [
+ "bold"
+ ]
+ }
+ },
+ "properties": {
+ "background_color": {
+ "$ref": "#/$defs/color"
+ },
+ "no_repeating": {
+ "type": "boolean"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ },
+ {
+ "$ref": "#/$defs/padding"
+ },
+ {
+ "$ref": "#/$defs/border"
+ },
+ {
+ "$ref": "#/$defs/cell_alignment"
+ }
+ ]
+ },
+ "cell_alignment": {
+ "type": "object",
+ "title": "Cell alignment properties",
+ "description": "Properties to set vertical and horizontal alignment of table cells",
+ "x-example": {
+ "align": "center",
+ "valign": "middle"
+ },
+ "properties": {
+ "align": {
+ "title": "Horizontal alignment",
+ "description": "Set the horizontal alignment of the content in a cell",
+ "examples": [
+ "center"
+ ],
+ "type": "string",
+ "enum": [
+ "left",
+ "center",
+ "right"
+ ]
+ },
+ "valign": {
+ "title": "Vertical alignment",
+ "description": "Border width only on the left side",
+ "examples": [
+ "middle"
+ ],
+ "type": "string",
+ "enum": [
+ "top",
+ "center",
+ "middle",
+ "bottom"
+ ]
+ }
+ }
+ },
+ "paragraph": {
+ "type": "object",
+ "title": "Markdown paragraph",
+ "properties": {
+ "align": {
+ "type": "string",
+ "enum": [
+ "left",
+ "center",
+ "right",
+ "justify"
+ ]
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ },
+ {
+ "$ref": "#/$defs/padding"
+ }
+ ]
+ },
+ "border": {
+ "type": "object",
+ "title": "Border Properties",
+ "description": "Properties to set borders",
+ "x-example": {
+ "border_color": "F000FF",
+ "border_color_top": "000FFF",
+ "border_color_bottom": "FFF000",
+ "no_border_left": true,
+ "no_border_right": true,
+ "border_width": "0.25mm",
+ "border_width_left": "0.5mm",
+ "border_width_right": "0.5mm"
+ },
+ "properties": {
+ "border_width": {
+ "title": "Border width",
+ "description": "One value for border line width on all sides",
+ "$ref": "#/$defs/measurement"
+ },
+ "border_width_left": {
+ "title": "Border width left",
+ "description": "Border width only on the left side",
+ "$ref": "#/$defs/measurement"
+ },
+ "border_width_top": {
+ "title": "Border width top",
+ "description": "Border width only on the top side",
+ "$ref": "#/$defs/measurement"
+ },
+ "border_width_right": {
+ "title": "Border width right",
+ "description": "Border width only on the right side",
+ "$ref": "#/$defs/measurement"
+ },
+ "border_width_bottom": {
+ "title": "Border width bottom",
+ "description": "Border width only on the bottom side",
+ "$ref": "#/$defs/measurement"
+ },
+ "border_color": {
+ "title": "Border color",
+ "description": "One value for border color on all sides",
+ "$ref": "#/$defs/color"
+ },
+ "border_color_left": {
+ "title": "Border color left",
+ "description": "Border color only on the left side",
+ "$ref": "#/$defs/color"
+ },
+ "border_color_top": {
+ "title": "Border color top",
+ "description": "Border color only on the top side",
+ "$ref": "#/$defs/color"
+ },
+ "border_color_right": {
+ "title": "Border color right",
+ "description": "Border color only on the right side",
+ "$ref": "#/$defs/color"
+ },
+ "border_color_bottom": {
+ "title": "Border color bottom",
+ "description": "Border color only on the bottom side",
+ "$ref": "#/$defs/color"
+ },
+ "no_border": {
+ "title": "Disable borders",
+ "description": "Turn off borders on all sides",
+ "type": "boolean"
+ },
+ "no_border_left": {
+ "title": "Disable border left",
+ "description": "Turn off border on the left sides",
+ "type": "boolean"
+ },
+ "no_border_top": {
+ "title": "Disable border top",
+ "description": "Turn off border on the top sides",
+ "type": "boolean"
+ },
+ "no_border_right": {
+ "title": "Disable border right",
+ "description": "Turn off border on the right sides",
+ "type": "boolean"
+ },
+ "no_border_bottom": {
+ "title": "Disable border bottom",
+ "description": "Turn off border on the bottom sides",
+ "type": "boolean"
+ }
+ }
+ },
+ "alert": {
+ "type": "object",
+ "title": "Alert",
+ "description": "Styling to denote a quote as alert box",
+ "x-example": {
+ "ALERT": {
+ "alert_color": "f4f9ff",
+ "border_color": "f4f9ff",
+ "border_width": 2,
+ "no_border_right": true,
+ "no_border_left": false,
+ "no_border_bottom": true,
+ "no_border_top": true
+ }
+ },
+ "properties": {
+ "background_color": {
+ "$ref": "#/$defs/color"
+ },
+ "alert_color": {
+ "$ref": "#/$defs/color"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ },
+ {
+ "$ref": "#/$defs/border"
+ },
+ {
+ "$ref": "#/$defs/padding"
+ },
+ {
+ "$ref": "#/$defs/margin"
+ }
+ ]
+ },
+ "alerts": {
+ "type": "object",
+ "title": "Alert boxes (styled blockquotes)",
+ "properties": {
+ "NOTE": {
+ "$ref": "#/$defs/alert"
+ },
+ "TIP": {
+ "$ref": "#/$defs/alert"
+ },
+ "WARNING": {
+ "$ref": "#/$defs/alert"
+ },
+ "IMPORTANT": {
+ "$ref": "#/$defs/alert"
+ },
+ "CAUTION": {
+ "$ref": "#/$defs/alert"
+ }
+ }
+ }
+ }
+}
diff --git a/app/models/projects/exports/pdf_export/standard.yml b/app/models/projects/exports/pdf_export/standard.yml
new file mode 100644
index 00000000000..8bf4229e676
--- /dev/null
+++ b/app/models/projects/exports/pdf_export/standard.yml
@@ -0,0 +1,321 @@
+page:
+ page_size: "A4"
+ margin_top: 80
+ margin_bottom: 60
+ margin_left: 40
+ margin_right: 40
+ page_break_threshold: 200
+ link_color: "175A8E"
+
+page_header:
+ offset: 20
+ size: 8
+
+page_footer:
+ offset: -30
+ size: 8
+ spacing: 6
+
+page_logo:
+ height: 20
+ align: "right"
+ offset: 10
+
+page_heading:
+ size: 14
+ styles: [ "bold" ]
+ margin_bottom: 10
+
+cover:
+ header:
+ logo_height: 25
+ border:
+ color: 'd3dee3'
+ height: 1
+ offset: 6
+ footer:
+ size: 10
+ color: '414d5f'
+ offset: 30
+ hero:
+ padding_right: 144
+ padding_top: 120
+ title:
+ max_height: 30
+ spacing: 10
+ font: 'SpaceMono'
+ color: '414d5f'
+ size: 10
+ heading:
+ spacing: 14
+ color: '414d5f'
+ styles:
+ - bold
+ size: 16
+ dates:
+ spacing: 4
+ max_height: 16
+ color: '414d5f'
+ size: 10
+ styles:
+ - bold
+ subheading:
+ max_height: 30
+ color: '414d5f'
+ size: 10
+
+toc:
+ title:
+ size: 16
+ color: "000000"
+ margin_bottom: 4
+ subject_indent: 4
+ indent_mode: "stairs"
+ margin_top: 10
+ margin_bottom: 20
+ item:
+ size: 10
+ color: "000000"
+ margin_bottom: 12
+ item_level_1:
+ size: 11
+ styles: [ "bold" ]
+ margin_top: 6
+ margin_bottom: 6
+ item_level_2:
+ size: 11
+ margin_top: 4
+ margin_bottom: 4
+ item_level_3:
+ size: 10
+ margin_top: 4
+ margin_bottom: 4
+ item_level_4:
+ size: 10
+ margin_top: 4
+ margin_bottom: 4
+ item_level_5:
+ size: 10
+ margin_top: 4
+ margin_bottom: 4
+
+project:
+ margin_bottom: 20
+ title:
+ size: 12
+ color: "000000"
+ margin_bottom: 8
+ styles: [ "bold" ]
+ markdown_label:
+ size: 10
+ color: "000000"
+ styles: [ "bold" ]
+ margin_bottom: 3
+ markdown_margins:
+ margin_bottom: 8
+ markdown:
+ font:
+ size: 10
+ leading: 3
+ header:
+ size: 8
+ styles: [ "bold" ]
+ padding_top: 4
+ header_1:
+ size: 10
+ header_2:
+ size: 10
+ header_3:
+ size: 9
+ paragraph:
+ align: "left"
+ unordered_list:
+ spacing: 1
+ unordered_list_point:
+ spacing: 4
+ ordered_list:
+ spacing: 1
+ ordered_list_point:
+ spacing: 4
+ spanning: true
+ list_style_type: decimal
+ ordered_list_point_2:
+ list_style_type: lower-latin
+ ordered_list_point_3:
+ list_style_type: lower-roman
+ ordered_list_point_4:
+ list_style_type: upper-latin
+ ordered_list_point_5:
+ list_style_type: upper-roman
+ task_list:
+ spacing: 1
+ task_list_point:
+ spacing: 4
+ checked: "☑"
+ unchecked: "☐"
+ link:
+ color: "175A8E"
+ styles: [ ]
+ code:
+ color: "880000"
+ size: 9
+ font: "SpaceMono"
+ blockquote:
+ background_color: "f4f9ff"
+ size: 10
+ styles: [ "italic" ]
+ color: "0f3b66"
+ border_color: "b8d6f4"
+ border_width: 1
+ padding: 4
+ padding_left: 6
+ margin_top: 4
+ margin_bottom: 4
+ no_border_left: false
+ no_border_right: true
+ no_border_bottom: true
+ no_border_top: true
+ image:
+ align: "center"
+ margin_bottom: 4
+ caption:
+ size: 8
+ align: "center"
+ codeblock:
+ background_color: "F5F5F5"
+ color: "880000"
+ padding: 10
+ size: 8
+ margin_top: 10
+ margin_bottom: 10
+ font: "SpaceMono"
+ table:
+ auto_width: true
+ margin_top: 4
+ margin_bottom: 4
+ header:
+ size: 9
+ styles: [ "bold" ]
+ background_color: "F0F0F0"
+ cell:
+ size: 8
+ border_width: 0.25
+ padding: 5
+ html_table:
+ auto_width: true
+ margin_top: 4
+ margin_bottom: 4
+ header:
+ size: 9
+ styles: [ "bold" ]
+ border_width: 0.25
+ border_color: '000000'
+ no_border: true
+ cell:
+ size: 8
+ padding: 5
+ border_width: 0.25
+ border_color: '000000'
+ no_border: true
+ valign: middle
+ alerts:
+ NOTE:
+ border_color: '0969da'
+ alert_color: '0969da'
+ padding: '4mm'
+ size: 10
+ styles: [ ]
+ border_width: 2
+ no_border_right: true
+ no_border_left: false
+ no_border_bottom: true
+ no_border_top: true
+ TIP:
+ border_color: '1a7f37'
+ alert_color: '1a7f37'
+ padding: '4mm'
+ size: 10
+ styles: [ ]
+ border_width: 2
+ no_border_right: true
+ no_border_left: false
+ no_border_bottom: true
+ no_border_top: true
+ IMPORTANT:
+ border_color: '8250df'
+ alert_color: '8250df'
+ padding: '4mm'
+ size: 10
+ styles: [ ]
+ border_width: 2
+ no_border_right: true
+ no_border_left: false
+ no_border_bottom: true
+ no_border_top: true
+ WARNING:
+ border_color: 'bf8700'
+ alert_color: 'bf8700'
+ padding: '4mm'
+ size: 10
+ styles: [ ]
+ border_width: 2
+ no_border_right: true
+ no_border_left: false
+ no_border_bottom: true
+ no_border_top: true
+ CAUTION:
+ border_color: 'd1242f'
+ alert_color: 'd1242f'
+ size: 10
+ styles: [ ]
+ padding: '4mm'
+ border_width: 2
+ no_border_right: true
+ no_border_left: false
+ no_border_bottom: true
+ no_border_top: true
+ attributes_group:
+ size: 11
+ styles: [ "bold" ]
+ margin_top: 0
+ margin_bottom: 4
+ hr:
+ color: "1F2328"
+ height: 0.25
+ attributes_table:
+ cell:
+ size: 10
+ color: "000000"
+ padding_left: 0
+ padding_right: 5
+ padding_top: 0
+ padding_bottom: 8
+ border_color: "4B4B4B"
+ border_width: 0.25
+ no_border: true
+ cell_label:
+ styles: [ "bold" ]
+ size: 10
+
+wp_table:
+ margins:
+ margin_top: 8
+ group_heading:
+ size: 11
+ styles: [ "bold" ]
+ margin_bottom: 10
+ table:
+ margin_bottom: 5
+ cell:
+ size: 9
+ color: "000000"
+ padding_left: 0
+ padding_right: 5
+ padding_top: 0
+ padding_bottom: 5
+ border_width: 0.25
+ no_border: true
+ cell_header:
+ size: 9
+ styles: [ "bold" ]
+
diff --git a/app/models/projects/exports/pdf_export/styles.rb b/app/models/projects/exports/pdf_export/styles.rb
new file mode 100644
index 00000000000..d7efdb91196
--- /dev/null
+++ b/app/models/projects/exports/pdf_export/styles.rb
@@ -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.
+#++
+
+module Projects::Exports::PDFExport::Styles
+ class PDFStyles
+ include MarkdownToPDF::Common
+ include MarkdownToPDF::StyleHelper
+ include Exports::PDF::Common::Styles
+ include Exports::PDF::Components::PageStyles
+ include Exports::PDF::Components::CoverStyles
+ include Exports::PDF::Components::WpTableStyles
+ include WorkPackage::PDFExport::Common::AttributesTableStyles
+
+ def project_title
+ resolve_font(@styles.dig(:project, :title))
+ end
+
+ def project_title_margins
+ resolve_margin(@styles.dig(:project, :title))
+ end
+
+ def project_margins
+ resolve_margin(@styles[:project])
+ end
+
+ def toc_title
+ resolve_font(@styles.dig(:toc, :title))
+ end
+
+ def toc_title_margins
+ resolve_margin(@styles.dig(:toc, :title))
+ end
+
+ def toc_max_depth
+ @styles.dig(:toc, :max_depth) || 4
+ end
+
+ def toc_margins
+ resolve_margin(@styles[:toc])
+ end
+
+ def toc_indent_mode
+ @styles.dig(:toc, :indent_mode)
+ end
+
+ def toc_item(level)
+ resolve_font(@styles.dig(:toc, :item)).merge(
+ resolve_font(@styles.dig(:toc, :"item_level_#{level}"))
+ )
+ end
+
+ def toc_item_subject_indent
+ resolve_pt(@styles.dig(:toc, :subject_indent), 4)
+ end
+
+ def toc_item_margins(level)
+ resolve_margin(@styles.dig(:toc, :item)).merge(
+ resolve_margin(@styles.dig(:toc, :"item_level_#{level}"))
+ )
+ end
+
+ def project_markdown_label
+ resolve_font(@styles.dig(:project, :markdown_label))
+ end
+
+ def project_markdown_label_margins
+ resolve_margin(@styles.dig(:project, :markdown_label))
+ end
+
+ def project_markdown_margins
+ resolve_margin(@styles.dig(:project, :markdown_margins))
+ end
+
+ def project_markdown_styling_yml
+ resolve_markdown_styling(@styles.dig(:project, :markdown) || {})
+ end
+
+ def project_attributes_table_margins
+ resolve_margin(@styles.dig(:project, :attributes_table))
+ end
+
+ def project_attributes_table_cell
+ resolve_table_cell(@styles.dig(:project, :attributes_table, :cell))
+ end
+
+ def project_attributes_table_label
+ resolve_font(@styles.dig(:project, :attributes_table, :cell_label))
+ end
+
+ def project_attributes_table_label_cell
+ project_attributes_table_cell.merge(
+ resolve_table_cell(@styles.dig(:project, :attributes_table, :cell_label)) || {}
+ )
+ end
+ end
+
+ def styles
+ @styles ||= PDFStyles.new(styles_asset_path)
+ end
+
+ private
+
+ def styles_asset_path
+ File.dirname(File.expand_path(__FILE__))
+ end
+end
diff --git a/app/models/projects/exports/pdf_export/table_of_content.rb b/app/models/projects/exports/pdf_export/table_of_content.rb
new file mode 100644
index 00000000000..17f7130059e
--- /dev/null
+++ b/app/models/projects/exports/pdf_export/table_of_content.rb
@@ -0,0 +1,179 @@
+# 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.
+#++
+
+module Projects::Exports::PDFExport
+ module TableOfContent
+ def render_toc(projects, info_map)
+ with_margin(styles.toc_title_margins) do
+ pdf.formatted_text([{ text: I18n.t("label_table_of_contents") }.merge(styles.toc_title)])
+ end
+ toc_list = build_toc_data_list(projects, info_map)
+ with_margin(styles.toc_margins) do
+ write_toc! toc_list
+ end
+ start_new_page_if_needed
+ end
+
+ def build_toc_data_list(projects, info_map)
+ projects.map do |project|
+ build_toc_data_list_entry(project, info_map)
+ end
+ end
+
+ def build_toc_data_list_entry(project, info_map)
+ project_info = info_map[project.id]
+ level_data = calculate_level_data(project_info[:level_path])
+ page_nr_string = (project_info[:page_number] || "000").to_s
+ build_toc_entry(project.id, project.name, level_data, page_nr_string)
+ end
+
+ def calculate_level_data(level_path)
+ level = [level_path.length, styles.toc_max_depth].min
+ level_style = styles.toc_item(level)
+ level_string = "#{level_path.join('.')}. "
+ {
+ level:,
+ level_style:,
+ level_string:,
+ level_string_width: measure_part_width(level_string, level_style)
+ }
+ end
+
+ def build_toc_entry(id, title, level_data, page_nr_string)
+ {
+ id:,
+ level_string: level_data[:level_string],
+ level_string_width: level_data[:level_string_width],
+ title:,
+ page_nr_string:,
+ page_nr_string_width: measure_part_width(page_nr_string, level_data[:level_style]),
+ level: level_data[:level]
+ }
+ end
+
+ def measure_part_width(part, part_style)
+ measure_text_width(part, part_style) + styles.toc_item_subject_indent
+ end
+
+ def write_toc!(toc_list)
+ levels_indent_list = toc_indent_list(toc_list)
+ toc_list.each do |toc_item|
+ with_margin(styles.toc_item_margins(toc_item[:level])) do
+ write_toc_item!(toc_item, levels_indent_list)
+ end
+ end
+ end
+
+ def toc_indent_list(toc_list)
+ levels = toc_list.pluck(:level).uniq.sort
+ level_max_widths = levels.map do |level|
+ toc_list.select { |item| item[:level] == level }.pluck(:level_string_width).max
+ end
+ mode = (styles.toc_indent_mode || :flat).to_sym
+ case mode
+ when :stairs
+ toc_indent_list_stairs(levels, level_max_widths)
+ when :third_level
+ toc_indent_list_third_level(levels, level_max_widths)
+ else
+ toc_indent_list_flat(levels, level_max_widths)
+ end
+ end
+
+ def toc_indent_list_flat(levels, level_max_widths)
+ levels_max_width = level_max_widths.max
+ levels.map do |_|
+ { level_indent: 0, subject_index: levels_max_width }
+ end
+ end
+
+ def toc_indent_list_third_level(levels, level_max_widths)
+ indent_list = []
+ first_section = level_max_widths[0..1].max || 0
+ second_section = level_max_widths[2..].max || 0
+ levels.each do |level|
+ if level < 3
+ indent_list.push({ level_indent: 0, subject_index: first_section })
+ else
+ indent_list.push({ level_indent: first_section, subject_index: first_section + second_section })
+ end
+ end
+ indent_list
+ end
+
+ def toc_indent_list_stairs(levels, level_max_widths)
+ indent_list = []
+ levels.each do |level|
+ level_indent = level <= 1 ? 0 : indent_list.last[:subject_index]
+ subject_index = level_indent + level_max_widths[level - 1]
+ indent_list.push({ level_indent:, subject_index: })
+ end
+ indent_list
+ end
+
+ def build_toc_item_styles(toc_item)
+ toc_item_style = styles.toc_item(toc_item[:level])
+ part_style = toc_item_style.clone
+ font_styles = part_style.delete(:styles) || []
+ part_style[:style] = font_styles[0] unless font_styles.empty?
+ [part_style, toc_item_style]
+ end
+
+ def write_toc_item!(toc_item, levels_indent_list)
+ y_start_position = pdf.y
+ part_style, toc_item_style = build_toc_item_styles(toc_item)
+ indent = levels_indent_list[toc_item[:level] - 1]
+
+ write_toc_part_float(indent[:level_indent], toc_item[:level_string], part_style)
+ write_toc_part_float(0, toc_item[:page_nr_string], part_style.merge({ align: :right }))
+ write_toc_item_subject(toc_item, indent[:subject_index], toc_item_style)
+ write_toc_item_link(toc_item, y_start_position)
+ end
+
+ def write_toc_item_subject(toc_item, indent, subject_style)
+ pdf.indent(indent, toc_item[:page_nr_string_width]) do
+ pdf.formatted_text([subject_style.merge({ text: toc_item[:title] })])
+ end
+ end
+
+ def write_toc_part_float(indent, part, part_style)
+ pdf.float do
+ pdf.indent(indent) do
+ pdf.text(part, part_style)
+ end
+ end
+ end
+
+ def write_toc_item_link(toc_item, y_start_position)
+ rect = [pdf.bounds.absolute_right, pdf.y, pdf.bounds.absolute_left, y_start_position]
+ pdf.link_annotation(rect, Border: [0, 0, 0], Dest: toc_item[:id].to_s)
+ end
+ end
+end
diff --git a/app/models/projects/exports/query_exporter.rb b/app/models/projects/exports/query_exporter.rb
index 8aaa0f7df09..53edc985c37 100644
--- a/app/models/projects/exports/query_exporter.rb
+++ b/app/models/projects/exports/query_exporter.rb
@@ -42,13 +42,17 @@ module Projects::Exports
end
def projects
- @projects ||= query
+ @projects ||= all_projects
+ .page(page)
+ .per_page(Setting.work_packages_projects_export_limit.to_i)
+ end
+
+ def all_projects
+ query
.results
.with_required_storage
.with_latest_activity
.includes(:custom_values)
- .page(page)
- .per_page(Setting.work_packages_projects_export_limit.to_i)
end
private
diff --git a/app/models/queries/serialization/hash.rb b/app/models/queries/serialization/hash.rb
index 0cd1831caaa..5fedad522ea 100644
--- a/app/models/queries/serialization/hash.rb
+++ b/app/models/queries/serialization/hash.rb
@@ -36,6 +36,7 @@ module Queries
class_methods do
def from_hash(hash) # rubocop:disable Metrics/AbcSize
new(user: hash[:user]).tap do |query|
+ query.name = hash[:name] if hash[:name].present?
query.add_filters hash[:filters] if hash[:filters].present?
query.add_orders hash[:orders] if hash[:orders].present?
query.group hash[:group_by] if hash[:group_by].present?
@@ -50,7 +51,8 @@ module Queries
orders: orders.map { |o| [o.attribute, o.direction] },
group_by: respond_to?(:group_by) ? group_by : nil,
selects: selects.map(&:attribute),
- user:
+ user:,
+ name:
}
end
diff --git a/app/workers/projects/export_job.rb b/app/workers/projects/export_job.rb
index 1dca464e6fb..93de1fc2a45 100644
--- a/app/workers/projects/export_job.rb
+++ b/app/workers/projects/export_job.rb
@@ -34,6 +34,10 @@ module Projects
class ExportJob < ::Exports::ExportJob
self.model = Project
+ def title
+ I18n.t("export.your_projects_export")
+ end
+
private
def prepare!
diff --git a/config/initializers/export_formats.rb b/config/initializers/export_formats.rb
index 8eab80fb8b5..5db06440cc9 100644
--- a/config/initializers/export_formats.rb
+++ b/config/initializers/export_formats.rb
@@ -50,12 +50,15 @@ Rails.application.configure do |application|
formatter WorkPackage, WorkPackage::Exports::Formatters::SpentUnits
list Project, Projects::Exports::CSV
+ list Project, Projects::Exports::PDF
formatter Project, Exports::Formatters::CustomField
formatter Project, Exports::Formatters::CustomFieldPdf
formatter Project, Projects::Exports::Formatters::Status
formatter Project, Projects::Exports::Formatters::Description
formatter Project, Projects::Exports::Formatters::Public
formatter Project, Projects::Exports::Formatters::Active
+ formatter Project, Projects::Exports::Formatters::Favored
+ formatter Project, Projects::Exports::Formatters::RequiredDiskSpace
end
end
end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index d733fa16300..dfaba42b492 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -2487,6 +2487,7 @@ en:
label: "Include descriptions"
caption: "This option will add a description column in raw format."
your_work_packages_export: "Work packages are being exported"
+ your_projects_export: "Projects are being exported"
succeeded: "Export completed"
failed: "An error has occurred while trying to export the work packages: %{message}"
errors:
diff --git a/frontend/src/stimulus/controllers/dynamic/job-dialog.controller.ts b/frontend/src/stimulus/controllers/dynamic/job-dialog.controller.ts
new file mode 100644
index 00000000000..33eab7a81aa
--- /dev/null
+++ b/frontend/src/stimulus/controllers/dynamic/job-dialog.controller.ts
@@ -0,0 +1,112 @@
+/*
+ * -- 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.
+ * ++
+ */
+
+import { ApplicationController } from 'stimulus-use';
+import { renderStreamMessage } from '@hotwired/turbo';
+import { HttpErrorResponse } from '@angular/common/http';
+import { TurboHelpers } from 'core-turbo/helpers';
+
+export default class AsyncJobDialogController extends ApplicationController {
+ static values = {
+ closeDialogId: String,
+ };
+
+ declare closeDialogIdValue:string;
+
+ connect() {
+ this.element.addEventListener('click', (e) => {
+ e.preventDefault();
+ TurboHelpers.showProgressBar();
+ this.closePreviousDialog();
+ this.requestJob().then((job_id) => {
+ if (job_id) {
+ return this.showJobModal(job_id);
+ }
+ return this.handleError('No job ID returned from server.');
+ })
+ .catch((error:HttpErrorResponse|string) => this.handleError(error))
+ .finally(() => {
+ TurboHelpers.hideProgressBar();
+ });
+ });
+ }
+
+ closePreviousDialog() {
+ if (!this.closeDialogIdValue) {
+ return; // No dialog ID specified, nothing to close
+ }
+ const dialog = document.getElementById(this.closeDialogIdValue) as HTMLDialogElement;
+ if (dialog) {
+ dialog.close();
+ }
+ }
+
+ async requestJob():Promise {
+ const response = await fetch(this.href, {
+ method: this.method,
+ headers: { Accept: 'application/json' },
+ credentials: 'same-origin',
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+ const result = await response.json() as { job_id:string };
+ if (!result.job_id) {
+ throw new Error('Invalid response from server');
+ }
+ return result.job_id;
+ }
+
+ async showJobModal(job_id:string) {
+ const response = await fetch(`/job_statuses/${job_id}/dialog`, {
+ method: 'GET',
+ headers: { Accept: 'text/vnd.turbo-stream.html' },
+ });
+ if (response.ok) {
+ renderStreamMessage(await response.text());
+ } else {
+ throw new Error(response.statusText || 'Invalid response from server');
+ }
+ }
+
+ async handleError(error:HttpErrorResponse|string):Promise {
+ void window.OpenProject.getPluginContext().then((pluginContext) => {
+ pluginContext.services.notifications.addError(error);
+ });
+ }
+
+ get href() {
+ return (this.element as HTMLLinkElement).href;
+ }
+
+ get method() {
+ return (this.element as HTMLLinkElement).dataset.jobHrefMethod || 'GET';
+ }
+}
diff --git a/script/pdf_export/styles.yml b/script/pdf_export/styles.yml
index 4c031000972..e05a77b1523 100644
--- a/script/pdf_export/styles.yml
+++ b/script/pdf_export/styles.yml
@@ -7,3 +7,5 @@ styles:
path: "modules/reporting/app/workers/cost_query/pdf"
- name: "meeting"
path: "modules/meeting/app/workers/meetings/pdf"
+ - name: "project-report"
+ path: "app/models/projects/exports/pdf_export"