From 5b5caaa1864a79ac0a06e3c99c87681bb29338de Mon Sep 17 00:00:00 2001 From: as-op Date: Thu, 31 Jul 2025 14:29:07 +0200 Subject: [PATCH] [#65994] PDF export of project list https://community.openproject.org/work_packages/65994 --- .../export_list_modal_component.html.erb | 2 + app/models/exports/pdf/common/common.rb | 2 +- .../projects/exports/formatters/active.rb | 4 +- .../projects/exports/formatters/favored.rb | 44 + .../projects/exports/formatters/public.rb | 2 +- .../exports/formatters/required_disk_space.rb | 46 + app/models/projects/exports/pdf.rb | 141 ++ .../projects/exports/pdf_export/info_map.rb | 80 + .../projects/exports/pdf_export/report.rb | 309 +++ .../projects/exports/pdf_export/schema.json | 1946 +++++++++++++++++ .../projects/exports/pdf_export/standard.yml | 321 +++ .../projects/exports/pdf_export/styles.rb | 133 ++ .../exports/pdf_export/table_of_content.rb | 179 ++ app/models/projects/exports/query_exporter.rb | 10 +- app/models/queries/serialization/hash.rb | 4 +- app/workers/projects/export_job.rb | 4 + config/initializers/export_formats.rb | 3 + config/locales/en.yml | 1 + .../dynamic/job-dialog.controller.ts | 112 + script/pdf_export/styles.yml | 2 + 20 files changed, 3336 insertions(+), 9 deletions(-) create mode 100644 app/models/projects/exports/formatters/favored.rb create mode 100644 app/models/projects/exports/formatters/required_disk_space.rb create mode 100644 app/models/projects/exports/pdf.rb create mode 100644 app/models/projects/exports/pdf_export/info_map.rb create mode 100644 app/models/projects/exports/pdf_export/report.rb create mode 100644 app/models/projects/exports/pdf_export/schema.json create mode 100644 app/models/projects/exports/pdf_export/standard.yml create mode 100644 app/models/projects/exports/pdf_export/styles.rb create mode 100644 app/models/projects/exports/pdf_export/table_of_content.rb create mode 100644 frontend/src/stimulus/controllers/dynamic/job-dialog.controller.ts 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"