From 8752302407a10fb2ff6b4326d501e0df8075b5bf Mon Sep 17 00:00:00 2001
From: as-op <a.sandorf@openproject.com>
Date: Thu, 30 May 2024 15:45:32 +0200
Subject: [PATCH 01/19] [#54377] Embedded work package attributes in PDF export

https://community.openproject.org/work_packages/54377
---
 Gemfile                                       |   2 +
 Gemfile.lock                                  |   1 +
 app/models/exports/formatters/custom_field.rb |   2 +-
 .../projects/exports/formatters/active.rb     |  42 +++++
 .../projects/exports/formatters/public.rb     |  42 +++++
 .../work_package/exports/macros/attributes.rb | 168 ++++++++++++++++++
 .../work_package/pdf_export/markdown_field.rb |  48 ++++-
 .../pdf_export/work_package_detail.rb         |   2 +-
 config/initializers/export_formats.rb         |   2 +
 .../pdf_export/work_package_to_pdf_spec.rb    | 152 ++++++++++++++--
 10 files changed, 442 insertions(+), 19 deletions(-)
 create mode 100644 app/models/projects/exports/formatters/active.rb
 create mode 100644 app/models/projects/exports/formatters/public.rb
 create mode 100644 app/models/work_package/exports/macros/attributes.rb

diff --git a/Gemfile b/Gemfile
index ca1db68f251a..9882d01a33d2 100644
--- a/Gemfile
+++ b/Gemfile
@@ -157,9 +157,11 @@ gem "structured_warnings", "~> 0.4.0"
 # don't require by default, instead load on-demand when actually configured
 gem "airbrake", "~> 13.0.0", require: false
 
+gem "markly", "~> 0.10"
 gem "md_to_pdf", git: "https://github.com/opf/md-to-pdf", ref: "8f14736a88ad0064d2a97be108fe7061ffbcee91"
 gem "prawn", "~> 2.4"
 gem "ttfunk", "~> 1.7.0" # remove after https://github.com/prawnpdf/prawn/issues/1346 resolved.
+
 # prawn implicitly depends on matrix gem no longer in ruby core with 3.1
 gem "matrix", "~> 0.4.2"
 
diff --git a/Gemfile.lock b/Gemfile.lock
index fac96af70f9a..d8c3ea81140e 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1233,6 +1233,7 @@ DEPENDENCIES
   lograge (~> 0.14.0)
   lookbook (~> 2.3.0)
   mail (= 2.8.1)
+  markly (~> 0.10)
   matrix (~> 0.4.2)
   md_to_pdf!
   meta-tags (~> 2.21.0)
diff --git a/app/models/exports/formatters/custom_field.rb b/app/models/exports/formatters/custom_field.rb
index 5815fb2a18ff..045b197bb9f2 100644
--- a/app/models/exports/formatters/custom_field.rb
+++ b/app/models/exports/formatters/custom_field.rb
@@ -24,7 +24,7 @@ def format_for_export(object, custom_field)
         case custom_field.field_format
         when "bool"
           value = object.typed_custom_value_for(custom_field)
-          value == nil ? false : value
+          value ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No)
         when "text"
           object.typed_custom_value_for(custom_field)
         else
diff --git a/app/models/projects/exports/formatters/active.rb b/app/models/projects/exports/formatters/active.rb
new file mode 100644
index 000000000000..81980c07a751
--- /dev/null
+++ b/app/models/projects/exports/formatters/active.rb
@@ -0,0 +1,42 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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 Active < ::Exports::Formatters::Default
+      def self.apply?(attribute, export_format)
+        export_format == :pdf && %i[active].include?(attribute.to_sym)
+      end
+
+      ##
+      # Takes a project and returns the localized status code
+      def format(project, **)
+        project.active ? 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
new file mode 100644
index 000000000000..40a6e85d1f0f
--- /dev/null
+++ b/app/models/projects/exports/formatters/public.rb
@@ -0,0 +1,42 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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 Public < ::Exports::Formatters::Default
+      def self.apply?(attribute, export_format)
+        export_format == :pdf && %i[public].include?(attribute.to_sym)
+      end
+
+      ##
+      # Takes a project and returns the localized status code
+      def format(project, **)
+        project.active ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No)
+      end
+    end
+  end
+end
diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb
new file mode 100644
index 000000000000..6ce70aee38a2
--- /dev/null
+++ b/app/models/work_package/exports/macros/attributes.rb
@@ -0,0 +1,168 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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 WorkPackage::Exports
+  module Macros
+    # OpenProject attribute macros syntax
+    # Examples:
+    #   workPackageLabel:1234:subject # Outputs work package label attribute "Subject" + help text
+    #   workPackageValue:1234:subject # Outputs the actual subject of #1234
+    #
+    #   projectLabel:statusExplanation # Outputs current project label attribute "Status description" + help text
+    #   projectValue:statusExplanation # Outputs current project value for "Status description"
+    class Attributes < OpenProject::TextFormatting::Matchers::RegexMatcher
+      DISABLED_PROJECT_RICH_TEXT_FIELDS = %i[description status_explanation status_description].freeze
+      DISABLED_WORK_PACKAGE_RICH_TEXT_FIELDS = %i[description].freeze
+
+      def self.regexp
+        %r{
+          (\w+)(Label|Value) # The model type we try to reference
+          (?::(?:([^"\s]+)|"([^"]+)"))? # Optional: An ID or subject reference
+          (?::([^"\s.]+|"([^".]+)")) # The attribute name we're trying to reference
+        }x
+      end
+
+      ##
+      # Faster inclusion check before the regex is being applied
+      def self.applicable?(content)
+        content.include?("Label:") || content.include?("Value:")
+      end
+
+      def self.process_match(match, _matched_string, context)
+        context => { user:, work_package: }
+        type = match[2].downcase
+        model_s = match[1]
+        id = match[4] || match[3]
+        attribute = match[6] || match[5]
+        resolve_match(type, model_s, id, attribute, work_package, user)
+      end
+
+      def self.resolve_match(type, model_s, id, attribute, work_package, user)
+        if model_s == "workPackage"
+          resolve_work_package_match(id || work_package.id, type, attribute, user)
+        elsif model_s == "project"
+          resolve_project_match(id || work_package.project.id, type, attribute, user)
+        else
+          "[Error: Invalid attribute macro: #{model_s}]"
+        end
+      end
+
+      def self.resolve_label_work_package(attribute)
+        resolve_label(WorkPackage, attribute)
+      end
+
+      def self.resolve_label_project(attribute)
+        resolve_label(Project, attribute)
+      end
+
+      def self.resolve_label(model, attribute)
+        model.human_attribute_name(
+          ::API::Utilities::PropertyNameConverter.to_ar_name(attribute.to_sym, context: model.new)
+        )
+      end
+
+      def self.resolve_work_package_match(id, type, attribute, user)
+        return resolve_label_work_package(attribute) if type == "label"
+        return "[Error: Invalid attribute macro: #{type}]" unless type == "value"
+
+        work_package = WorkPackage.find_by(id:)
+        if work_package.nil? || !user.allowed_in_project?(:view_work_packages, work_package.project)
+          return "[Error: #{WorkPackage.name} #{id} not found}]"
+        end
+
+        resolve_value_work_package(work_package, attribute)
+      end
+
+      def self.resolve_project_match(id, type, attribute, user)
+        return resolve_label_project(attribute) if type == "label"
+        return "[Error: Invalid attribute macro: #{type}]" unless type == "value"
+
+        project = Project.find_by(id:)
+        if project.nil? || !user.allowed_in_project?(:view_project, project)
+          return "[Error: #{Project.name} #{id} not found}]"
+        end
+
+        resolve_value_project(project, attribute)
+      end
+
+      def self.escape_tags(value)
+        # only disable html tags, but do not replace html entities
+        value.to_s.gsub("<", "&lt;").gsub(">", "&gt;")
+      end
+
+      def self.resolve_value_project(project, attribute)
+        cf = CustomField.find_by(name: attribute, type: "ProjectCustomField")
+        if cf.nil?
+          ar_name = ::API::Utilities::PropertyNameConverter.to_ar_name(attribute.to_sym, context: project)
+        else
+          ar_name = "cf_#{cf.id}"
+          # currently we do not support embedding rich text fields: long text custom fields
+          return "[Rich text embedding currently not supported in export]" if cf.formattable?
+
+          # TODO: Is the user allowed to see this custom field/"project attribute"?
+        end
+
+        # currently we do not support embedding rich text field: e.g. projectValue:1234:description
+        if DISABLED_PROJECT_RICH_TEXT_FIELDS.include?(ar_name.to_sym)
+          return "[Rich text embedding currently not supported in export]"
+
+        end
+
+        format_attribute_value(ar_name, Project, project)
+      end
+
+      def self.resolve_value_work_package(work_package, attribute)
+        cf = CustomField.find_by(name: attribute, type: "WorkPackageCustomField")
+        if cf.nil?
+          ar_name = ::API::Utilities::PropertyNameConverter.to_ar_name(attribute.to_sym, context: work_package)
+        else
+          ar_name = "cf_#{cf.id}"
+          # currently we do not support embedding rich text fields: long text custom fields
+          return "[Rich text embedding currently not supported in export]" if cf.formattable?
+
+          # TODO: Are there access restrictions on work_package custom fields?
+        end
+
+        # currently we do not support embedding rich text field: workPackageValue:1234:description
+        if DISABLED_WORK_PACKAGE_RICH_TEXT_FIELDS.include?(ar_name.to_sym)
+          return "[Rich text embedding currently not supported in export]"
+
+        end
+
+        format_attribute_value(ar_name, WorkPackage, work_package)
+      end
+
+      def self.format_attribute_value(ar_name, model, obj)
+        formatter = Exports::Register.formatter_for(model, ar_name, :pdf)
+        value = formatter.format(obj)
+        # important NOT to return empty string as this could change meaning of markdown
+        # e.g. **to_be_replaced** could be rendered as **** (horizontal line and a *)
+        value.blank? ? " " : escape_tags(value)
+      end
+    end
+  end
+end
diff --git a/app/models/work_package/pdf_export/markdown_field.rb b/app/models/work_package/pdf_export/markdown_field.rb
index 963396d30067..b3a6447b8a94 100644
--- a/app/models/work_package/pdf_export/markdown_field.rb
+++ b/app/models/work_package/pdf_export/markdown_field.rb
@@ -28,6 +28,7 @@
 
 module WorkPackage::PDFExport::MarkdownField
   include WorkPackage::PDFExport::Markdown
+  PREFORMATTED_BLOCKS = %w(pre code).freeze
 
   def write_markdown_field!(work_package, markdown, label)
     return if markdown.blank?
@@ -37,7 +38,52 @@ def write_markdown_field!(work_package, markdown, label)
       pdf.formatted_text([styles.wp_markdown_label.merge({ text: label })])
     end
     with_margin(styles.wp_markdown_margins) do
-      write_markdown! work_package, markdown
+      write_markdown! work_package, apply_markdown_field_macros(markdown)
+    end
+  end
+
+  private
+
+  def apply_markdown_field_macros(markdown)
+    apply_macros(markdown, WorkPackage::Exports::Macros::Attributes)
+  end
+
+  def apply_macros(markdown, formatter)
+    return markdown unless formatter.applicable?(markdown)
+
+    document = Markly.parse(markdown)
+    document.walk do |node|
+      if node.type == :html
+        node.string_content = apply_macro_html(node.string_content, work_package, formatter)
+      elsif node.type == :text
+        node.string_content = apply_macro_text(node.string_content, work_package, formatter)
+      end
+    end
+    document.to_markdown
+  end
+
+  def apply_macro_text(text, work_package, formatter)
+    return text unless formatter.applicable?(text)
+
+    text.gsub!(formatter.regexp) do |matched_string|
+      matchdata = Regexp.last_match
+      formatter.process_match(matchdata, matched_string, { user: User.current, work_package: })
+    end
+  end
+
+  def apply_macro_html(html, work_package, formatter)
+    return html unless formatter.applicable?(html)
+
+    doc = Nokogiri::HTML.fragment(html)
+    apply_macro_html_node(doc, work_package, formatter)
+    doc.to_html
+  end
+
+  def apply_macro_html_node(node, work_package, formatter)
+    if node.text?
+      node.content = apply_macro_text(node.content, work_package, formatter)
+    elsif PREFORMATTED_BLOCKS.exclude?(node.name)
+      node.children.each { |child| apply_macro_html_node(child, work_package, formatter) }
     end
   end
 end
diff --git a/app/models/work_package/pdf_export/work_package_detail.rb b/app/models/work_package/pdf_export/work_package_detail.rb
index 2d4aae33e8e7..2f575c12bdc6 100644
--- a/app/models/work_package/pdf_export/work_package_detail.rb
+++ b/app/models/work_package/pdf_export/work_package_detail.rb
@@ -143,7 +143,7 @@ def form_key_to_column_entries(form_key, work_package)
 
       return [] unless cf.is_for_all? || work_package.project.work_package_custom_field_ids.include?(cf.id)
 
-      return [{ label: cf.name || form_key, name: form_key }]
+      return [{ label: cf.name || form_key, name: form_key.to_s.sub("custom_field_", "cf_") }]
     end
 
     if form_key == :date
diff --git a/config/initializers/export_formats.rb b/config/initializers/export_formats.rb
index f95759cdf163..26f2092b4128 100644
--- a/config/initializers/export_formats.rb
+++ b/config/initializers/export_formats.rb
@@ -20,6 +20,8 @@
       formatter Project, Exports::Formatters::CustomField
       formatter Project, Projects::Exports::Formatters::Status
       formatter Project, Projects::Exports::Formatters::Description
+      formatter Project, Projects::Exports::Formatters::Public
+      formatter Project, Projects::Exports::Formatters::Active
     end
   end
 end
diff --git a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb
index f6fd8f60c8db..4491fbbd77e7 100644
--- a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb
+++ b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb
@@ -36,10 +36,17 @@
       t.attribute_groups.first.attributes.push(cf_disabled_in_project.attribute_name, cf_long_text.attribute_name)
     end
   end
+  let(:parent_project) do
+    create(:project, name: "Parent project")
+  end
   let(:project) do
     create(:project,
            name: "Foo Bla. Report No. 4/2021 with/for Case 42",
            types: [type],
+           public: true,
+           status_code: "on_track",
+           active: true,
+           parent: parent_project,
            work_package_custom_fields: [cf_long_text, cf_disabled_in_project, cf_global_bool],
            work_package_custom_field_ids: [cf_long_text.id, cf_global_bool.id]) # cf_disabled_in_project.id is disabled
   end
@@ -47,11 +54,15 @@
     create(:user,
            member_with_permissions: { project => %w[view_work_packages export_work_packages] })
   end
+  let(:category) { create(:category, project:, name: "Demo") }
+  let(:version) { create(:version, project:) }
   let(:export_time) { DateTime.new(2023, 6, 30, 23, 59) }
   let(:export_time_formatted) { format_time(export_time, true) }
   let(:image_path) { Rails.root.join("spec/fixtures/files/image.png") }
+  let(:priority) { create(:priority_normal) }
   let(:image_attachment) { Attachment.new author: user, file: File.open(image_path) }
   let(:attachments) { [image_attachment] }
+  let(:cf_long_text_description) { "foo" }
   let(:cf_long_text) { create(:issue_custom_field, :text, name: "LongText") }
   let!(:cf_disabled_in_project) do
     # NOT enabled by project.work_package_custom_field_ids => NOT in PDF
@@ -65,8 +76,11 @@
       default_value: true
     )
   end
-  let(:work_package) do
-    description = <<~DESCRIPTION
+  let(:status) { create(:status, name: "random", is_default: true) }
+  let!(:parent_work_package) { create(:work_package, type:, subject: "Parent wp") }
+
+  let(:description) do
+    <<~DESCRIPTION
       **Lorem** _ipsum_ ~~dolor~~ `sit` [amet](https://example.com/), consetetur sadipscing elitr.
       <mention data-text="@OpenProject Admin">@OpenProject Admin</mention>
       ![](/api/v3/attachments/#{image_attachment.id}/content)
@@ -80,14 +94,32 @@
       </p>
       <p><unknown-tag>Foo</unknown-tag></p>
     DESCRIPTION
+  end
+  let(:work_package) do
     create(:work_package,
+           id: 1,
            project:,
            type:,
            subject: "Work package 1",
+           start_date: "2024-05-30",
+           due_date: "2024-05-30",
+           created_at: export_time,
+           updated_at: export_time,
+           author: user,
+           assigned_to: user,
+           responsible: user,
            story_points: 1,
+           estimated_hours: 10,
+           done_ratio: 25,
+           remaining_hours: 9,
+           parent: parent_work_package,
+           priority:,
+           version:,
+           status:,
+           category:,
            description:,
            custom_values: {
-             cf_long_text.id => "foo",
+             cf_long_text.id => cf_long_text_description,
              cf_disabled_in_project.id => "6.25",
              cf_global_bool.id => true
            }).tap do |wp|
@@ -109,6 +141,15 @@
       export.export!
     end
   end
+  let(:expected_details) do
+    exporter.send(:attributes_data_by_wp, work_package)
+            .flat_map do |item|
+      value = get_column_value(item[:name])
+      result = [item[:label].upcase]
+      result << value if value.present?
+      result
+    end
+  end
 
   def get_column_value(column_name)
     formatter = Exports::Register.formatter_for(WorkPackage, column_name, :pdf)
@@ -126,18 +167,10 @@ def get_column_value(column_name)
 
   describe "with a request for a PDF" do
     it "contains correct data" do
-      details = exporter.send(:attributes_data_by_wp, work_package)
-                        .flat_map do |item|
-        value = get_column_value(item[:name])
-        result = [item[:label].upcase]
-        result << value if value.present?
-        result
-      end
-      # Joining the results for comparison since word wrapping leads to a different array for the same content
-      result = pdf[:strings].join(" ")
+      result = pdf[:strings]
       expected_result = [
         "#{type.name} ##{work_package.id} - #{work_package.subject}",
-        *details,
+        *expected_details,
         label_title(:description),
         "Lorem", " ", "ipsum", " ", "dolor", " ", "sit", " ",
         "amet", ", consetetur sadipscing elitr.", " ", "@OpenProject Admin",
@@ -145,10 +178,97 @@ def get_column_value(column_name)
         "Foo",
         "LongText", "foo",
         "1", export_time_formatted, project.name
-      ].join(" ")
-      expect(result).to eq(expected_result)
-      expect(result).not_to include("DisabledCustomField")
+      ].flatten
+      # Joining the results for comparison since word wrapping leads to a different array for the same content
+      expect(result.join(" ")).to eq(expected_result.join(" "))
+      expect(result.join(" ")).not_to include("DisabledCustomField")
       expect(pdf[:images].length).to eq(2)
     end
+
+    describe "with embedded attributes" do
+      let(:supported_work_package_embeds) do
+        [
+          ["assignee", user.name],
+          ["author", user.name],
+          ["category", category.name],
+          ["createdAt", export_time_formatted],
+          ["updatedAt", export_time_formatted],
+          ["estimatedTime", "10.0 h"],
+          ["remainingTime", "9.0 h"],
+          ["version", version.name],
+          ["responsible", user.name],
+          ["dueDate", "05/30/2024"],
+          ["spentTime", "0.0 h"],
+          ["startDate", "05/30/2024"],
+          ["parent", "#{type.name} ##{parent_work_package.id}: #{parent_work_package.name}"],
+          ["priority", priority.name],
+          ["project", project.name],
+          ["status", status.name],
+          ["subject", "Work package 1"],
+          ["type", type.name],
+          ["description", "[Rich text embedding currently not supported in export]"]
+        ]
+      end
+      let(:supported_project_embeds) do
+        [
+          ["active", "Yes"],
+          ["description", "[Rich text embedding currently not supported in export]"],
+          ["identifier", project.identifier],
+          ["name", project.name],
+          ["status", I18n.t("activerecord.attributes.project.status_codes.#{project.status_code}")],
+          ["statusExplanation", "[Rich text embedding currently not supported in export]"],
+          ["parent", parent_project.name],
+          ["public", "Yes"]
+        ]
+      end
+      let(:supported_work_package_embeds_table) do
+        supported_work_package_embeds.map do |embed|
+          "<tr><td>workPackageLabel:#{embed[0]}</td><td>workPackageValue:#{embed[0]}</td></tr>"
+        end
+      end
+      let(:description) do
+        <<~DESCRIPTION
+          ## Work package attributes and labels
+          <table><tbody>#{supported_work_package_embeds_table}</tbody></table>
+        DESCRIPTION
+      end
+      let(:supported_project_embeds_table) do
+        supported_project_embeds.map do |embed|
+          "<tr><td>projectLabel:#{embed[0]}</td><td>projectValue:#{embed[0]}</td></tr>"
+        end
+      end
+      let(:cf_long_text_description) do
+        <<~DESCRIPTION
+          ## Project attributes and labels
+          <table><tbody>#{supported_project_embeds_table}</tbody></table>
+        DESCRIPTION
+      end
+
+      it "contains resolved attributes and labels" do
+        result = pdf[:strings]
+        expected_result = [
+          "#{type.name} ##{work_package.id} - #{work_package.subject}",
+          *expected_details,
+          label_title(:description),
+          "Work package attributes and labels",
+          supported_work_package_embeds.map do |embed|
+            [WorkPackage.human_attribute_name(
+              API::Utilities::PropertyNameConverter.to_ar_name(embed[0].to_sym, context: work_package)
+            ), embed[1]]
+          end,
+          "1", export_time_formatted, project.name,
+          "LongText",
+          "Project attributes and labels",
+          supported_project_embeds.map do |embed|
+            [Project.human_attribute_name(
+              API::Utilities::PropertyNameConverter.to_ar_name(embed[0].to_sym, context: project)
+            ), embed[1]]
+          end,
+          "2", export_time_formatted, project.name
+        ].flatten
+        # Joining the results for comparison since word wrapping leads to a different array for the same content
+        expect(result.join(" ")).to eq(expected_result.join(" "))
+      end
+    end
   end
 end

From d01ba3be1cc0ee5d373b7da63026e05343bd3a1c Mon Sep 17 00:00:00 2001
From: as-op <a.sandorf@openproject.com>
Date: Thu, 30 May 2024 15:49:50 +0200
Subject: [PATCH 02/19] fix(lint): obey rubocop

---
 .../pdf_export/work_package_detail.rb          | 18 +++++++++++-------
 1 file changed, 11 insertions(+), 7 deletions(-)

diff --git a/app/models/work_package/pdf_export/work_package_detail.rb b/app/models/work_package/pdf_export/work_package_detail.rb
index 2f575c12bdc6..632ea724c8fc 100644
--- a/app/models/work_package/pdf_export/work_package_detail.rb
+++ b/app/models/work_package/pdf_export/work_package_detail.rb
@@ -135,15 +135,19 @@ def form_configuration_columns(work_package)
     end.flatten
   end
 
-  def form_key_to_column_entries(form_key, work_package)
-    if CustomField.custom_field_attribute? form_key
-      id = form_key.to_s.sub("custom_field_", "").to_i
-      cf = CustomField.find_by(id:)
-      return [] if cf.nil? || cf.formattable?
+  def form_key_custom_field_to_column_entries(form_key, work_package)
+    id = form_key.to_s.sub("custom_field_", "").to_i
+    cf = CustomField.find_by(id:)
+    return [] if cf.nil? || cf.formattable?
+
+    return [] unless cf.is_for_all? || work_package.project.work_package_custom_field_ids.include?(cf.id)
 
-      return [] unless cf.is_for_all? || work_package.project.work_package_custom_field_ids.include?(cf.id)
+    [{ label: cf.name || form_key, name: form_key.to_s.sub("custom_field_", "cf_") }]
+  end
 
-      return [{ label: cf.name || form_key, name: form_key.to_s.sub("custom_field_", "cf_") }]
+  def form_key_to_column_entries(form_key, work_package)
+    if CustomField.custom_field_attribute? form_key
+      return form_key_custom_field_to_column_entries(form_key, work_package)
     end
 
     if form_key == :date

From 9b958f8ce3f96eedc5d7c325262464af8727f272 Mon Sep 17 00:00:00 2001
From: as-op <a.sandorf@openproject.com>
Date: Mon, 3 Jun 2024 13:31:07 +0200
Subject: [PATCH 03/19] fix(export): pdf uses Yes/No for boolean custom fields;
 XLS & CVS do not

---
 app/models/exports/formatters/custom_field.rb |  6 ++---
 .../exports/formatters/custom_field_pdf.rb    | 27 +++++++++++++++++++
 config/initializers/export_formats.rb         |  1 +
 3 files changed, 31 insertions(+), 3 deletions(-)
 create mode 100644 app/models/exports/formatters/custom_field_pdf.rb

diff --git a/app/models/exports/formatters/custom_field.rb b/app/models/exports/formatters/custom_field.rb
index 045b197bb9f2..66dac9606b14 100644
--- a/app/models/exports/formatters/custom_field.rb
+++ b/app/models/exports/formatters/custom_field.rb
@@ -1,8 +1,8 @@
 module Exports
   module Formatters
     class CustomField < Default
-      def self.apply?(attribute, _export_format)
-        attribute.start_with?("cf_")
+      def self.apply?(attribute, export_format)
+        export_format != :pdf && attribute.start_with?("cf_")
       end
 
       ##
@@ -24,7 +24,7 @@ def format_for_export(object, custom_field)
         case custom_field.field_format
         when "bool"
           value = object.typed_custom_value_for(custom_field)
-          value ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No)
+          value == nil ? false : value
         when "text"
           object.typed_custom_value_for(custom_field)
         else
diff --git a/app/models/exports/formatters/custom_field_pdf.rb b/app/models/exports/formatters/custom_field_pdf.rb
new file mode 100644
index 000000000000..a299f785a169
--- /dev/null
+++ b/app/models/exports/formatters/custom_field_pdf.rb
@@ -0,0 +1,27 @@
+module Exports
+  module Formatters
+    class CustomFieldPdf < CustomField
+      def self.apply?(attribute, export_format)
+        export_format == :pdf && attribute.start_with?("cf_")
+      end
+
+      ##
+      # Print the value meant for export.
+      #
+      # - For boolean values, use the Yes/No formatting for the PDF
+      #   treat nil as false
+      # - For long text values, output the plain value
+      def format_for_export(object, custom_field)
+        case custom_field.field_format
+        when "bool"
+          value = object.typed_custom_value_for(custom_field)
+          value ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No)
+        when "text"
+          object.typed_custom_value_for(custom_field)
+        else
+          object.formatted_custom_value_for(custom_field)
+        end
+      end
+    end
+  end
+end
diff --git a/config/initializers/export_formats.rb b/config/initializers/export_formats.rb
index 26f2092b4128..3b959de78632 100644
--- a/config/initializers/export_formats.rb
+++ b/config/initializers/export_formats.rb
@@ -15,6 +15,7 @@
       formatter WorkPackage, WorkPackage::Exports::Formatters::Costs
       formatter WorkPackage, WorkPackage::Exports::Formatters::DoneRatio
       formatter WorkPackage, Exports::Formatters::CustomField
+      formatter WorkPackage, Exports::Formatters::CustomFieldPdf
 
       list Project, Projects::Exports::CSV
       formatter Project, Exports::Formatters::CustomField

From e40050c19040fd1b1fdb55c9444250f30992a5b7 Mon Sep 17 00:00:00 2001
From: as-op <a.sandorf@openproject.com>
Date: Mon, 3 Jun 2024 15:15:27 +0200
Subject: [PATCH 04/19] extract I18n strings, adjust specs, test for excluded
 replacements e.g. markdown/html code blocks

---
 .../work_package/exports/macros/attributes.rb | 30 +++++++++++++------
 config/locales/en.yml                         |  6 ++++
 .../pdf_export/work_package_to_pdf_spec.rb    | 23 +++++++++++---
 3 files changed, 46 insertions(+), 13 deletions(-)

diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb
index 6ce70aee38a2..2fcc46d4be1d 100644
--- a/app/models/work_package/exports/macros/attributes.rb
+++ b/app/models/work_package/exports/macros/attributes.rb
@@ -67,10 +67,22 @@ def self.resolve_match(type, model_s, id, attribute, work_package, user)
         elsif model_s == "project"
           resolve_project_match(id || work_package.project.id, type, attribute, user)
         else
-          "[Error: Invalid attribute macro: #{model_s}]"
+          msg_macro_error I18n.t('export.macro.model_not_found', model: model_s)
         end
       end
 
+      def self.msg_macro_error(message)
+        msg_inline I18n.t('export.macro.error', message:)
+      end
+
+      def self.msg_macro_error_rich_text
+        msg_inline I18n.t('export.macro.rich_text_unsupported')
+      end
+
+      def self.msg_inline(message)
+        "[#{message}]"
+      end
+
       def self.resolve_label_work_package(attribute)
         resolve_label(WorkPackage, attribute)
       end
@@ -87,11 +99,11 @@ def self.resolve_label(model, attribute)
 
       def self.resolve_work_package_match(id, type, attribute, user)
         return resolve_label_work_package(attribute) if type == "label"
-        return "[Error: Invalid attribute macro: #{type}]" unless type == "value"
+        return msg_macro_error(I18n.t('export.macro.model_not_found', model: type)) unless type == "value"
 
         work_package = WorkPackage.find_by(id:)
         if work_package.nil? || !user.allowed_in_project?(:view_work_packages, work_package.project)
-          return "[Error: #{WorkPackage.name} #{id} not found}]"
+          return msg_macro_error(I18n.t('export.macro.resource_not_found', resource: "#{WorkPackage.name} #{id}"))
         end
 
         resolve_value_work_package(work_package, attribute)
@@ -99,11 +111,11 @@ def self.resolve_work_package_match(id, type, attribute, user)
 
       def self.resolve_project_match(id, type, attribute, user)
         return resolve_label_project(attribute) if type == "label"
-        return "[Error: Invalid attribute macro: #{type}]" unless type == "value"
+        return msg_macro_error(I18n.t("export.macro.model_not_found", model: type)) unless type == "value"
 
         project = Project.find_by(id:)
         if project.nil? || !user.allowed_in_project?(:view_project, project)
-          return "[Error: #{Project.name} #{id} not found}]"
+          return msg_macro_error(I18n.t("export.macro.resource_not_found", resource: "#{Project.name} #{id}"))
         end
 
         resolve_value_project(project, attribute)
@@ -121,14 +133,14 @@ def self.resolve_value_project(project, attribute)
         else
           ar_name = "cf_#{cf.id}"
           # currently we do not support embedding rich text fields: long text custom fields
-          return "[Rich text embedding currently not supported in export]" if cf.formattable?
+          return msg_macro_error_rich_text if cf.formattable?
 
           # TODO: Is the user allowed to see this custom field/"project attribute"?
         end
 
         # currently we do not support embedding rich text field: e.g. projectValue:1234:description
         if DISABLED_PROJECT_RICH_TEXT_FIELDS.include?(ar_name.to_sym)
-          return "[Rich text embedding currently not supported in export]"
+          return msg_macro_error_rich_text
 
         end
 
@@ -142,14 +154,14 @@ def self.resolve_value_work_package(work_package, attribute)
         else
           ar_name = "cf_#{cf.id}"
           # currently we do not support embedding rich text fields: long text custom fields
-          return "[Rich text embedding currently not supported in export]" if cf.formattable?
+          return msg_macro_error_rich_text if cf.formattable?
 
           # TODO: Are there access restrictions on work_package custom fields?
         end
 
         # currently we do not support embedding rich text field: workPackageValue:1234:description
         if DISABLED_WORK_PACKAGE_RICH_TEXT_FIELDS.include?(ar_name.to_sym)
-          return "[Rich text embedding currently not supported in export]"
+          return msg_macro_error_rich_text
 
         end
 
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 5c221a64da4c..5b079c47a579 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -1705,6 +1705,12 @@ Project attributes and sections are defined in the <a href=%{admin_settings_url}
       pdf_gantt: "PDF Gantt"
     image:
       omitted: "Image not exported."
+    macro:
+      error: "Macro error: %{message}"
+      attribute_not_found: "Attribute not found: %{attribute}"
+      model_not_found: "Invalid attribute model: %{model}"
+      resource_not_found: "Invalid attribute model: %{resource}"
+      rich_text_unsupported: "Rich text embedding currently not supported in export."
     units:
       hours: h
       days: d
diff --git a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb
index 4491fbbd77e7..82f89de5e779 100644
--- a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb
+++ b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb
@@ -206,17 +206,17 @@ def get_column_value(column_name)
           ["status", status.name],
           ["subject", "Work package 1"],
           ["type", type.name],
-          ["description", "[Rich text embedding currently not supported in export]"]
+          ["description", "[#{I18n.t('export.macro.rich_text_unsupported')}]"]
         ]
       end
       let(:supported_project_embeds) do
         [
           ["active", "Yes"],
-          ["description", "[Rich text embedding currently not supported in export]"],
+          ["description", "[#{I18n.t('export.macro.rich_text_unsupported')}]"],
           ["identifier", project.identifier],
           ["name", project.name],
           ["status", I18n.t("activerecord.attributes.project.status_codes.#{project.status_code}")],
-          ["statusExplanation", "[Rich text embedding currently not supported in export]"],
+          ["statusExplanation", "[#{I18n.t('export.macro.rich_text_unsupported')}]"],
           ["parent", parent_project.name],
           ["public", "Yes"]
         ]
@@ -229,7 +229,19 @@ def get_column_value(column_name)
       let(:description) do
         <<~DESCRIPTION
           ## Work package attributes and labels
-          <table><tbody>#{supported_work_package_embeds_table}</tbody></table>
+          <table><tbody>#{supported_work_package_embeds_table}
+            <tr><td>No replacement of:</td><td>
+                <code>workPackageValue:1:assignee</code>
+                <code>workPackageLabel:assignee</code>
+            </td></tr>
+            </tbody></table>
+
+            `workPackageValue:2:assignee workPackageLabel:assignee`
+
+            ```
+            workPackageValue:3:assignee
+            workPackageLabel:assignee
+            ```
         DESCRIPTION
       end
       let(:supported_project_embeds_table) do
@@ -256,7 +268,10 @@ def get_column_value(column_name)
               API::Utilities::PropertyNameConverter.to_ar_name(embed[0].to_sym, context: work_package)
             ), embed[1]]
           end,
+          "No replacement of:", "workPackageValue:1:assignee", " ", "workPackageLabel:assignee",
+          "workPackageValue:2:assignee workPackageLabel:assignee",
           "1", export_time_formatted, project.name,
+          "workPackageValue:3:assignee", "workPackageLabel:assignee",
           "LongText",
           "Project attributes and labels",
           supported_project_embeds.map do |embed|

From 6c356b111131726e7453c76f7ff2c2a8df4ef0d5 Mon Sep 17 00:00:00 2001
From: as-op <a.sandorf@openproject.com>
Date: Tue, 4 Jun 2024 12:28:08 +0200
Subject: [PATCH 05/19] test for permissions on attribute embeds

---
 .../work_package/exports/macros/attributes.rb |   6 +-
 config/locales/en.yml                         |  10 +-
 .../pdf_export/work_package_to_pdf_spec.rb    | 207 +++++++++++++-----
 3 files changed, 159 insertions(+), 64 deletions(-)

diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb
index 2fcc46d4be1d..659e5db19562 100644
--- a/app/models/work_package/exports/macros/attributes.rb
+++ b/app/models/work_package/exports/macros/attributes.rb
@@ -102,7 +102,8 @@ def self.resolve_work_package_match(id, type, attribute, user)
         return msg_macro_error(I18n.t('export.macro.model_not_found', model: type)) unless type == "value"
 
         work_package = WorkPackage.find_by(id:)
-        if work_package.nil? || !user.allowed_in_project?(:view_work_packages, work_package.project)
+        if work_package.nil? ||
+          !user.allowed_in_work_package?(:view_work_packages, work_package)
           return msg_macro_error(I18n.t('export.macro.resource_not_found', resource: "#{WorkPackage.name} #{id}"))
         end
 
@@ -114,7 +115,8 @@ def self.resolve_project_match(id, type, attribute, user)
         return msg_macro_error(I18n.t("export.macro.model_not_found", model: type)) unless type == "value"
 
         project = Project.find_by(id:)
-        if project.nil? || !user.allowed_in_project?(:view_project, project)
+        if project.nil? ||
+          !user.allowed_in_project?(:view_project, project)
           return msg_macro_error(I18n.t("export.macro.resource_not_found", resource: "#{Project.name} #{id}"))
         end
 
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 5b079c47a579..802636d26d12 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -1706,11 +1706,11 @@ Project attributes and sections are defined in the <a href=%{admin_settings_url}
     image:
       omitted: "Image not exported."
     macro:
-      error: "Macro error: %{message}"
-      attribute_not_found: "Attribute not found: %{attribute}"
-      model_not_found: "Invalid attribute model: %{model}"
-      resource_not_found: "Invalid attribute model: %{resource}"
-      rich_text_unsupported: "Rich text embedding currently not supported in export."
+      error: "Macro error, %{message}"
+      attribute_not_found: "attribute not found: %{attribute}"
+      model_not_found: "invalid attribute model: %{model}"
+      resource_not_found: "resource not found: %{resource}"
+      rich_text_unsupported: "Rich text embedding currently not supported in export"
     units:
       hours: h
       days: d
diff --git a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb
index 82f89de5e779..208e33e6cba8 100644
--- a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb
+++ b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb
@@ -50,10 +50,25 @@
            work_package_custom_fields: [cf_long_text, cf_disabled_in_project, cf_global_bool],
            work_package_custom_field_ids: [cf_long_text.id, cf_global_bool.id]) # cf_disabled_in_project.id is disabled
   end
+  let(:forbidden_project) do
+    create(:project,
+           name: "Forbidden project",
+           types: [type],
+           id: 666,
+           public: true,
+           status_code: "on_track",
+           active: true,
+           parent: parent_project,
+           work_package_custom_fields: [cf_long_text, cf_disabled_in_project, cf_global_bool],
+           work_package_custom_field_ids: [cf_long_text.id, cf_global_bool.id]) # cf_disabled_in_project.id is disabled
+  end
   let(:user) do
     create(:user,
            member_with_permissions: { project => %w[view_work_packages export_work_packages] })
   end
+  let(:another_user) do
+    create(:user, firstname: "Secret User")
+  end
   let(:category) { create(:category, project:, name: "Demo") }
   let(:version) { create(:version, project:) }
   let(:export_time) { DateTime.new(2023, 6, 30, 23, 59) }
@@ -62,8 +77,9 @@
   let(:priority) { create(:priority_normal) }
   let(:image_attachment) { Attachment.new author: user, file: File.open(image_path) }
   let(:attachments) { [image_attachment] }
-  let(:cf_long_text_description) { "foo" }
-  let(:cf_long_text) { create(:issue_custom_field, :text, name: "LongText") }
+  let(:cf_long_text_description) { "" }
+  let(:cf_long_text) { create(:issue_custom_field, :text,
+                              name: "Work Package Custom Field Long Text") }
   let!(:cf_disabled_in_project) do
     # NOT enabled by project.work_package_custom_field_ids => NOT in PDF
     create(:float_wp_custom_field, name: "DisabledCustomField")
@@ -71,6 +87,7 @@
   let(:cf_global_bool) do
     create(
       :work_package_custom_field,
+      name: "Work Package Custom Field Boolean",
       field_format: "bool",
       is_for_all: true,
       default_value: true
@@ -78,7 +95,6 @@
   end
   let(:status) { create(:status, name: "random", is_default: true) }
   let!(:parent_work_package) { create(:work_package, type:, subject: "Parent wp") }
-
   let(:description) do
     <<~DESCRIPTION
       **Lorem** _ipsum_ ~~dolor~~ `sit` [amet](https://example.com/), consetetur sadipscing elitr.
@@ -128,6 +144,24 @@
               .and_return attachments
     end
   end
+  let(:forbidden_work_package) do
+    create(:work_package,
+           id: 10,
+           project: forbidden_project,
+           type:,
+           subject: "forbidden Work package",
+           start_date: "2024-05-30",
+           due_date: "2024-05-30",
+           created_at: export_time,
+           updated_at: export_time,
+           author: another_user,
+           assigned_to: another_user
+    ).tap do |wp|
+      allow(wp)
+        .to receive(:attachments)
+              .and_return attachments
+    end
+  end
   let(:options) { {} }
   let(:exporter) do
     described_class.new(work_package, options)
@@ -142,13 +176,14 @@
     end
   end
   let(:expected_details) do
-    exporter.send(:attributes_data_by_wp, work_package)
-            .flat_map do |item|
-      value = get_column_value(item[:name])
-      result = [item[:label].upcase]
-      result << value if value.present?
-      result
-    end
+    ["#{type.name} ##{work_package.id} - #{work_package.subject}"] +
+      exporter.send(:attributes_data_by_wp, work_package)
+              .flat_map do |item|
+        value = get_column_value(item[:name])
+        result = [item[:label].upcase]
+        result << value if value.present?
+        result
+      end
   end
 
   def get_column_value(column_name)
@@ -166,26 +201,28 @@ def get_column_value(column_name)
   end
 
   describe "with a request for a PDF" do
-    it "contains correct data" do
-      result = pdf[:strings]
-      expected_result = [
-        "#{type.name} ##{work_package.id} - #{work_package.subject}",
-        *expected_details,
-        label_title(:description),
-        "Lorem", " ", "ipsum", " ", "dolor", " ", "sit", " ",
-        "amet", ", consetetur sadipscing elitr.", " ", "@OpenProject Admin",
-        "Image Caption",
-        "Foo",
-        "LongText", "foo",
-        "1", export_time_formatted, project.name
-      ].flatten
-      # Joining the results for comparison since word wrapping leads to a different array for the same content
-      expect(result.join(" ")).to eq(expected_result.join(" "))
-      expect(result.join(" ")).not_to include("DisabledCustomField")
-      expect(pdf[:images].length).to eq(2)
+    describe "with rich text and images" do
+      let(:cf_long_text_description) { "foo" }
+      it "contains correct data" do
+        result = pdf[:strings]
+        expected_result = [
+          *expected_details,
+          label_title(:description),
+          "Lorem", " ", "ipsum", " ", "dolor", " ", "sit", " ",
+          "amet", ", consetetur sadipscing elitr.", " ", "@OpenProject Admin",
+          "Image Caption",
+          "Foo",
+          cf_long_text.name, "foo",
+          "1", export_time_formatted, project.name
+        ].flatten
+        # Joining the results for comparison since word wrapping leads to a different array for the same content
+        expect(result.join(" ")).to eq(expected_result.join(" "))
+        expect(result.join(" ")).not_to include("DisabledCustomField")
+        expect(pdf[:images].length).to eq(2)
+      end
     end
 
-    describe "with embedded attributes" do
+    describe "with embedded work package attributes" do
       let(:supported_work_package_embeds) do
         [
           ["assignee", user.name],
@@ -209,18 +246,6 @@ def get_column_value(column_name)
           ["description", "[#{I18n.t('export.macro.rich_text_unsupported')}]"]
         ]
       end
-      let(:supported_project_embeds) do
-        [
-          ["active", "Yes"],
-          ["description", "[#{I18n.t('export.macro.rich_text_unsupported')}]"],
-          ["identifier", project.identifier],
-          ["name", project.name],
-          ["status", I18n.t("activerecord.attributes.project.status_codes.#{project.status_code}")],
-          ["statusExplanation", "[#{I18n.t('export.macro.rich_text_unsupported')}]"],
-          ["parent", parent_project.name],
-          ["public", "Yes"]
-        ]
-      end
       let(:supported_work_package_embeds_table) do
         supported_work_package_embeds.map do |embed|
           "<tr><td>workPackageLabel:#{embed[0]}</td><td>workPackageValue:#{embed[0]}</td></tr>"
@@ -230,6 +255,12 @@ def get_column_value(column_name)
         <<~DESCRIPTION
           ## Work package attributes and labels
           <table><tbody>#{supported_work_package_embeds_table}
+            <tr><td>Custom field boolean</td><td>
+                workPackageValue:1:"#{cf_global_bool.name}"
+            </td></tr>
+            <tr><td>Custom field rich text</td><td>
+                workPackageValue:1:"#{cf_long_text.name}"
+            </td></tr>
             <tr><td>No replacement of:</td><td>
                 <code>workPackageValue:1:assignee</code>
                 <code>workPackageLabel:assignee</code>
@@ -242,24 +273,16 @@ def get_column_value(column_name)
             workPackageValue:3:assignee
             workPackageLabel:assignee
             ```
+
+            Work package not found:
+            workPackageValue:1234567890:assignee
+            Access denied:
+            workPackageValue:#{forbidden_work_package.id}:assignee
         DESCRIPTION
       end
-      let(:supported_project_embeds_table) do
-        supported_project_embeds.map do |embed|
-          "<tr><td>projectLabel:#{embed[0]}</td><td>projectValue:#{embed[0]}</td></tr>"
-        end
-      end
-      let(:cf_long_text_description) do
-        <<~DESCRIPTION
-          ## Project attributes and labels
-          <table><tbody>#{supported_project_embeds_table}</tbody></table>
-        DESCRIPTION
-      end
-
       it "contains resolved attributes and labels" do
         result = pdf[:strings]
         expected_result = [
-          "#{type.name} ##{work_package.id} - #{work_package.subject}",
           *expected_details,
           label_title(:description),
           "Work package attributes and labels",
@@ -268,20 +291,90 @@ def get_column_value(column_name)
               API::Utilities::PropertyNameConverter.to_ar_name(embed[0].to_sym, context: work_package)
             ), embed[1]]
           end,
+          "Custom field boolean", I18n.t(:general_text_Yes),
+          "1", export_time_formatted, project.name,
+          "Custom field rich text", "[#{I18n.t('export.macro.rich_text_unsupported')}]",
           "No replacement of:", "workPackageValue:1:assignee", " ", "workPackageLabel:assignee",
           "workPackageValue:2:assignee workPackageLabel:assignee",
-          "1", export_time_formatted, project.name,
           "workPackageValue:3:assignee", "workPackageLabel:assignee",
-          "LongText",
+          "Work package not found:  ",
+          "[#{I18n.t('export.macro.error', message:
+            I18n.t('export.macro.resource_not_found', resource: "WorkPackage 1234567890"))}]  ",
+          "Access denied:  ",
+          "[#{I18n.t('export.macro.error', message:
+            I18n.t('export.macro.resource_not_found', resource: "WorkPackage #{forbidden_work_package.id}"))}]",
+          "2", export_time_formatted, project.name
+        ].flatten
+        expect(result.join(" ")).to eq(expected_result.join(" "))
+      end
+    end
+
+    describe "with embedded project attributes" do
+      let(:supported_project_embeds) do
+        [
+          ["active", I18n.t(:general_text_Yes)],
+          ["description", "[#{I18n.t('export.macro.rich_text_unsupported')}]"],
+          ["identifier", project.identifier],
+          ["name", project.name],
+          ["status", I18n.t("activerecord.attributes.project.status_codes.#{project.status_code}")],
+          ["statusExplanation", "[#{I18n.t('export.macro.rich_text_unsupported')}]"],
+          ["parent", parent_project.name],
+          ["public", I18n.t(:general_text_Yes)]
+        ]
+      end
+      let(:supported_project_embeds_table) do
+        supported_project_embeds.map do |embed|
+          "<tr><td>projectLabel:#{embed[0]}</td><td>projectValue:#{embed[0]}</td></tr>"
+        end
+      end
+      let(:description) do
+        <<~DESCRIPTION
+          ## Project attributes and labels
+          <table><tbody>#{supported_project_embeds_table}
+            <tr><td>No replacement of:</td><td>
+                <code>projectValue:1:status</code>
+                <code>projectLabel:status</code>
+            </td></tr>
+            </tbody></table>
+
+            `projectValue:2:status projectLabel:status`
+
+            ```
+            projectValue:3:status
+            projectLabel:status
+            ```
+
+            Project not found:
+            projectValue:1234567890:active
+            Access denied:
+            projectValue:#{forbidden_project.id}:active
+        DESCRIPTION
+      end
+      it "contains resolved attributes and labels" do
+        result = pdf[:strings]
+        expected_result = [
+          *expected_details,
+          label_title(:description),
           "Project attributes and labels",
           supported_project_embeds.map do |embed|
             [Project.human_attribute_name(
               API::Utilities::PropertyNameConverter.to_ar_name(embed[0].to_sym, context: project)
             ), embed[1]]
           end,
-          "2", export_time_formatted, project.name
+
+          "No replacement of:", "projectValue:1:status", " ", "projectLabel:status",
+          "projectValue:2:status projectLabel:status",
+          "projectValue:3:status", "projectLabel:status",
+
+          "Project not found:  ",
+          "[#{I18n.t('export.macro.error', message:
+            I18n.t('export.macro.resource_not_found', resource: "Project 1234567890"))}]  ",
+          "Access denied:  ",
+          "[#{I18n.t('export.macro.error', message:
+            I18n.t('export.macro.resource_not_found', resource: "Project #{forbidden_project.id}"))}]",
+
+          "1", export_time_formatted, project.name
         ].flatten
-        # Joining the results for comparison since word wrapping leads to a different array for the same content
         expect(result.join(" ")).to eq(expected_result.join(" "))
       end
     end

From 9ff727c505d264fd503337bc5e6f71b79babde64 Mon Sep 17 00:00:00 2001
From: as-op <a.sandorf@openproject.com>
Date: Tue, 4 Jun 2024 13:02:04 +0200
Subject: [PATCH 06/19] check for display rights of project attributes

---
 app/models/exports/formatters/custom_field.rb        | 2 +-
 app/models/work_package/exports/macros/attributes.rb | 5 ++++-
 config/initializers/export_formats.rb                | 1 +
 3 files changed, 6 insertions(+), 2 deletions(-)

diff --git a/app/models/exports/formatters/custom_field.rb b/app/models/exports/formatters/custom_field.rb
index 66dac9606b14..ef55bd0aff25 100644
--- a/app/models/exports/formatters/custom_field.rb
+++ b/app/models/exports/formatters/custom_field.rb
@@ -6,7 +6,7 @@ def self.apply?(attribute, export_format)
       end
 
       ##
-      # Takes a WorkPackage and an attribute and returns the value to be exported.
+      # Takes a WorkPackage or Project and an attribute and returns the value to be exported.
       def retrieve_value(object)
         custom_field = find_custom_field(object)
         return "" if custom_field.nil?
diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb
index 659e5db19562..eb48c6be8632 100644
--- a/app/models/work_package/exports/macros/attributes.rb
+++ b/app/models/work_package/exports/macros/attributes.rb
@@ -137,7 +137,10 @@ def self.resolve_value_project(project, attribute)
           # currently we do not support embedding rich text fields: long text custom fields
           return msg_macro_error_rich_text if cf.formattable?
 
-          # TODO: Is the user allowed to see this custom field/"project attribute"?
+          # Is the user allowed to see this custom field/"project attribute"?
+          if project.available_custom_fields.find { |pcf| pcf.id == cf.id }.nil?
+            return msg_macro_error(I18n.t("export.macro.resource_not_found", resource: attribute))
+          end
         end
 
         # currently we do not support embedding rich text field: e.g. projectValue:1234:description
diff --git a/config/initializers/export_formats.rb b/config/initializers/export_formats.rb
index 3b959de78632..9d7603cef5e9 100644
--- a/config/initializers/export_formats.rb
+++ b/config/initializers/export_formats.rb
@@ -19,6 +19,7 @@
 
       list Project, Projects::Exports::CSV
       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

From ae934bb2aa0ddd0e43f4bf21264a44df4382d8b2 Mon Sep 17 00:00:00 2001
From: as-op <a.sandorf@openproject.com>
Date: Tue, 4 Jun 2024 13:38:31 +0200
Subject: [PATCH 07/19] test for display rights of project attributes

---
 .../pdf_export/work_package_to_pdf_spec.rb    | 32 ++++++++++++++++++-
 1 file changed, 31 insertions(+), 1 deletion(-)

diff --git a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb
index 208e33e6cba8..814f76bc9a18 100644
--- a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb
+++ b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb
@@ -39,6 +39,19 @@
   let(:parent_project) do
     create(:project, name: "Parent project")
   end
+  let(:project_custom_field_bool) { create(:project_custom_field, :boolean,
+                                           name: "Boolean project custom field") }
+  let(:project_custom_field_string) {
+    create(:project_custom_field, :string,
+           name: "Secret string", default_value: "admin eyes only",
+           visible: false)
+  }
+  let(:project_custom_field_long_text) {
+    create(:project_custom_field, :text,
+           name: "Rich text project custom field",
+           default_value: "rich text field value"
+    )
+  }
   let(:project) do
     create(:project,
            name: "Foo Bla. Report No. 4/2021 with/for Case 42",
@@ -47,6 +60,9 @@
            status_code: "on_track",
            active: true,
            parent: parent_project,
+           custom_field_values: {
+             project_custom_field_bool.id => true
+           },
            work_package_custom_fields: [cf_long_text, cf_disabled_in_project, cf_global_bool],
            work_package_custom_field_ids: [cf_long_text.id, cf_global_bool.id]) # cf_disabled_in_project.id is disabled
   end
@@ -55,7 +71,7 @@
            name: "Forbidden project",
            types: [type],
            id: 666,
-           public: true,
+           public: false,
            status_code: "on_track",
            active: true,
            parent: parent_project,
@@ -331,6 +347,15 @@ def get_column_value(column_name)
         <<~DESCRIPTION
           ## Project attributes and labels
           <table><tbody>#{supported_project_embeds_table}
+          <tr><td>Custom field boolean</td><td>
+                projectValue:"#{project_custom_field_bool.name}"
+            </td></tr>
+            <tr><td>Custom field rich text</td><td>
+                projectValue:"#{project_custom_field_long_text.name}"
+            </td></tr>
+            <tr><td>Custom field hidden</td><td>
+                projectValue:"#{project_custom_field_string.name}"
+            </td></tr>
             <tr><td>No replacement of:</td><td>
                 <code>projectValue:1:status</code>
                 <code>projectLabel:status</code>
@@ -361,6 +386,11 @@ def get_column_value(column_name)
               API::Utilities::PropertyNameConverter.to_ar_name(embed[0].to_sym, context: project)
             ), embed[1]]
           end,
+          "Custom field boolean", I18n.t(:general_text_Yes),
+          "Custom field rich text", "[#{I18n.t('export.macro.rich_text_unsupported')}]",
+          "Custom field hidden",
+          "[#{I18n.t('export.macro.error', message:
+            I18n.t('export.macro.resource_not_found', resource: "Secret string"))}]",
 
           "No replacement of:", "projectValue:1:status", " ", "projectLabel:status",
           "projectValue:2:status projectLabel:status",

From 96df4f5cd3952bdc0926eccc7a42d4d3c2c5f1f2 Mon Sep 17 00:00:00 2001
From: as-op <a.sandorf@openproject.com>
Date: Tue, 4 Jun 2024 15:47:01 +0200
Subject: [PATCH 08/19] fix missing variable for wp list to pdf report

---
 app/models/work_package/pdf_export/markdown_field.rb | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/app/models/work_package/pdf_export/markdown_field.rb b/app/models/work_package/pdf_export/markdown_field.rb
index b3a6447b8a94..ddb7648c8b0f 100644
--- a/app/models/work_package/pdf_export/markdown_field.rb
+++ b/app/models/work_package/pdf_export/markdown_field.rb
@@ -38,17 +38,17 @@ def write_markdown_field!(work_package, markdown, label)
       pdf.formatted_text([styles.wp_markdown_label.merge({ text: label })])
     end
     with_margin(styles.wp_markdown_margins) do
-      write_markdown! work_package, apply_markdown_field_macros(markdown)
+      write_markdown! work_package, apply_markdown_field_macros(markdown, work_package)
     end
   end
 
   private
 
-  def apply_markdown_field_macros(markdown)
-    apply_macros(markdown, WorkPackage::Exports::Macros::Attributes)
+  def apply_markdown_field_macros(markdown, work_package)
+    apply_macros(markdown, work_package, WorkPackage::Exports::Macros::Attributes)
   end
 
-  def apply_macros(markdown, formatter)
+  def apply_macros(markdown, work_package, formatter)
     return markdown unless formatter.applicable?(markdown)
 
     document = Markly.parse(markdown)

From bb8634bac90b0f31077abeb9ca855110340910be Mon Sep 17 00:00:00 2001
From: as-op <a.sandorf@openproject.com>
Date: Wed, 5 Jun 2024 15:24:32 +0200
Subject: [PATCH 09/19] use ProjectCustomField.find_by

https://github.com/opf/openproject/pull/15702#discussion_r1627286353
---
 app/models/work_package/exports/macros/attributes.rb | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb
index eb48c6be8632..a2867942c59c 100644
--- a/app/models/work_package/exports/macros/attributes.rb
+++ b/app/models/work_package/exports/macros/attributes.rb
@@ -129,7 +129,7 @@ def self.escape_tags(value)
       end
 
       def self.resolve_value_project(project, attribute)
-        cf = CustomField.find_by(name: attribute, type: "ProjectCustomField")
+        cf = ProjectCustomField.find_by(name: attribute)
         if cf.nil?
           ar_name = ::API::Utilities::PropertyNameConverter.to_ar_name(attribute.to_sym, context: project)
         else

From 3664727f15ede418e2ac9d0206037e71d356a8b8 Mon Sep 17 00:00:00 2001
From: as-op <a.sandorf@openproject.com>
Date: Wed, 5 Jun 2024 15:25:38 +0200
Subject: [PATCH 10/19] add markly comment

https://github.com/opf/openproject/pull/15702#discussion_r1627296963
---
 Gemfile | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Gemfile b/Gemfile
index 9882d01a33d2..d6bac00979bb 100644
--- a/Gemfile
+++ b/Gemfile
@@ -157,7 +157,7 @@ gem "structured_warnings", "~> 0.4.0"
 # don't require by default, instead load on-demand when actually configured
 gem "airbrake", "~> 13.0.0", require: false
 
-gem "markly", "~> 0.10"
+gem "markly", "~> 0.10" # another markdown parser like commonmarker, but with AST support used in PDF export
 gem "md_to_pdf", git: "https://github.com/opf/md-to-pdf", ref: "8f14736a88ad0064d2a97be108fe7061ffbcee91"
 gem "prawn", "~> 2.4"
 gem "ttfunk", "~> 1.7.0" # remove after https://github.com/prawnpdf/prawn/issues/1346 resolved.

From 558b80cba09a3e2374090b3747a831794b2bb561 Mon Sep 17 00:00:00 2001
From: as-op <a.sandorf@openproject.com>
Date: Wed, 5 Jun 2024 15:25:53 +0200
Subject: [PATCH 11/19] fix typo

---
 app/models/work_package/pdf_export/work_package_list_to_pdf.rb | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/models/work_package/pdf_export/work_package_list_to_pdf.rb b/app/models/work_package/pdf_export/work_package_list_to_pdf.rb
index 7de6d091f45a..f933b5f6ee7d 100644
--- a/app/models/work_package/pdf_export/work_package_list_to_pdf.rb
+++ b/app/models/work_package/pdf_export/work_package_list_to_pdf.rb
@@ -73,7 +73,7 @@ def export!
   rescue Prawn::Errors::CannotFit
     error(I18n.t(:error_pdf_export_too_many_columns))
   rescue StandardError => e
-    Rails.logger.error { "Failed to generated PDF export: #{e}." }
+    Rails.logger.error { "Failed to generate PDF export: #{e}." }
     error(I18n.t(:error_pdf_failed_to_export, error: e.message[0..300]))
   end
 

From 0f1e67be14fa3cfe6e257d20d1e37798b403a3e0 Mon Sep 17 00:00:00 2001
From: as-op <a.sandorf@openproject.com>
Date: Wed, 5 Jun 2024 15:36:34 +0200
Subject: [PATCH 12/19] fix bug and comment in attribute formatter
 project.public

---
 app/models/projects/exports/formatters/public.rb | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/app/models/projects/exports/formatters/public.rb b/app/models/projects/exports/formatters/public.rb
index 40a6e85d1f0f..deb4deb03011 100644
--- a/app/models/projects/exports/formatters/public.rb
+++ b/app/models/projects/exports/formatters/public.rb
@@ -33,9 +33,9 @@ def self.apply?(attribute, export_format)
       end
 
       ##
-      # Takes a project and returns the localized status code
+      # Takes a project and returns yes/no depending on the public attribute
       def format(project, **)
-        project.active ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No)
+        project.public? ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No)
       end
     end
   end

From 08602866f2a55917312978d194ee06049dcd5f6c Mon Sep 17 00:00:00 2001
From: as-op <a.sandorf@openproject.com>
Date: Wed, 5 Jun 2024 15:36:45 +0200
Subject: [PATCH 13/19] fix comment in attribute formatter project.active

---
 app/models/projects/exports/formatters/active.rb | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/app/models/projects/exports/formatters/active.rb b/app/models/projects/exports/formatters/active.rb
index 81980c07a751..93a78add3542 100644
--- a/app/models/projects/exports/formatters/active.rb
+++ b/app/models/projects/exports/formatters/active.rb
@@ -33,9 +33,9 @@ def self.apply?(attribute, export_format)
       end
 
       ##
-      # Takes a project and returns the localized status code
+      # Takes a project and returns yes/no depending on the active attribute
       def format(project, **)
-        project.active ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No)
+        project.active? ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No)
       end
     end
   end

From 076b56073bcf06f99139e20870b54b0fa9aafdc8 Mon Sep 17 00:00:00 2001
From: as-op <a.sandorf@openproject.com>
Date: Wed, 5 Jun 2024 15:45:08 +0200
Subject: [PATCH 14/19] simplify wp access check & find
 https://github.com/opf/openproject/pull/15702#discussion_r1627409100

---
 app/models/work_package/exports/macros/attributes.rb | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb
index a2867942c59c..99e8631ef425 100644
--- a/app/models/work_package/exports/macros/attributes.rb
+++ b/app/models/work_package/exports/macros/attributes.rb
@@ -101,9 +101,8 @@ def self.resolve_work_package_match(id, type, attribute, user)
         return resolve_label_work_package(attribute) if type == "label"
         return msg_macro_error(I18n.t('export.macro.model_not_found', model: type)) unless type == "value"
 
-        work_package = WorkPackage.find_by(id:)
-        if work_package.nil? ||
-          !user.allowed_in_work_package?(:view_work_packages, work_package)
+        work_package = WorkPackage.visible(user).find_by(id:)
+        if work_package.nil?
           return msg_macro_error(I18n.t('export.macro.resource_not_found', resource: "#{WorkPackage.name} #{id}"))
         end
 

From f578226fba26a3ec70d1c6cb8a8377454da81cfc Mon Sep 17 00:00:00 2001
From: as-op <a.sandorf@openproject.com>
Date: Wed, 5 Jun 2024 15:46:05 +0200
Subject: [PATCH 15/19] simplify project access check & find

https://github.com/opf/openproject/pull/15702#discussion_r1627409660
---
 app/models/work_package/exports/macros/attributes.rb | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb
index 99e8631ef425..90a8e9f892ab 100644
--- a/app/models/work_package/exports/macros/attributes.rb
+++ b/app/models/work_package/exports/macros/attributes.rb
@@ -113,9 +113,8 @@ def self.resolve_project_match(id, type, attribute, user)
         return resolve_label_project(attribute) if type == "label"
         return msg_macro_error(I18n.t("export.macro.model_not_found", model: type)) unless type == "value"
 
-        project = Project.find_by(id:)
-        if project.nil? ||
-          !user.allowed_in_project?(:view_project, project)
+        project = Project.visible(user).find_by(id:)
+        if project.nil?
           return msg_macro_error(I18n.t("export.macro.resource_not_found", resource: "#{Project.name} #{id}"))
         end
 

From 6ab7b50e97c33b171dcf20209a633fb393f4daff Mon Sep 17 00:00:00 2001
From: as-op <a.sandorf@openproject.com>
Date: Wed, 5 Jun 2024 16:43:30 +0200
Subject: [PATCH 16/19] simplify & unify attribute value resolving

https://github.com/opf/openproject/pull/15702#discussion_r1627436214
https://github.com/opf/openproject/pull/15702#discussion_r1627444854
---
 .../work_package/exports/macros/attributes.rb | 48 +++++--------------
 .../pdf_export/work_package_to_pdf_spec.rb    | 11 +++--
 2 files changed, 19 insertions(+), 40 deletions(-)

diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb
index 90a8e9f892ab..fa979b5e4d51 100644
--- a/app/models/work_package/exports/macros/attributes.rb
+++ b/app/models/work_package/exports/macros/attributes.rb
@@ -127,48 +127,26 @@ def self.escape_tags(value)
       end
 
       def self.resolve_value_project(project, attribute)
-        cf = ProjectCustomField.find_by(name: attribute)
-        if cf.nil?
-          ar_name = ::API::Utilities::PropertyNameConverter.to_ar_name(attribute.to_sym, context: project)
-        else
-          ar_name = "cf_#{cf.id}"
-          # currently we do not support embedding rich text fields: long text custom fields
-          return msg_macro_error_rich_text if cf.formattable?
-
-          # Is the user allowed to see this custom field/"project attribute"?
-          if project.available_custom_fields.find { |pcf| pcf.id == cf.id }.nil?
-            return msg_macro_error(I18n.t("export.macro.resource_not_found", resource: attribute))
-          end
-        end
-
-        # currently we do not support embedding rich text field: e.g. projectValue:1234:description
-        if DISABLED_PROJECT_RICH_TEXT_FIELDS.include?(ar_name.to_sym)
-          return msg_macro_error_rich_text
-
-        end
-
-        format_attribute_value(ar_name, Project, project)
+        resolve_value(project, attribute, DISABLED_PROJECT_RICH_TEXT_FIELDS)
       end
 
       def self.resolve_value_work_package(work_package, attribute)
-        cf = CustomField.find_by(name: attribute, type: "WorkPackageCustomField")
-        if cf.nil?
-          ar_name = ::API::Utilities::PropertyNameConverter.to_ar_name(attribute.to_sym, context: work_package)
-        else
-          ar_name = "cf_#{cf.id}"
-          # currently we do not support embedding rich text fields: long text custom fields
-          return msg_macro_error_rich_text if cf.formattable?
+        resolve_value(work_package, attribute, DISABLED_WORK_PACKAGE_RICH_TEXT_FIELDS)
+      end
 
-          # TODO: Are there access restrictions on work_package custom fields?
-        end
+      def self.resolve_value(obj, attribute, disabled_rich_text_fields)
+        cf = obj.available_custom_fields.find { |pcf| pcf.name == attribute }
 
-        # currently we do not support embedding rich text field: workPackageValue:1234:description
-        if DISABLED_WORK_PACKAGE_RICH_TEXT_FIELDS.include?(ar_name.to_sym)
-          return msg_macro_error_rich_text
+        return msg_macro_error_rich_text if cf&.formattable?
 
-        end
+        ar_name = if cf.nil?
+                    ::API::Utilities::PropertyNameConverter.to_ar_name(attribute.to_sym, context: obj)
+                  else
+                    "cf_#{cf.id}"
+                  end
+        return msg_macro_error_rich_text if disabled_rich_text_fields.include?(ar_name.to_sym)
 
-        format_attribute_value(ar_name, WorkPackage, work_package)
+        format_attribute_value(ar_name, obj.class, obj)
       end
 
       def self.format_attribute_value(ar_name, model, obj)
diff --git a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb
index 814f76bc9a18..9a703b1fb544 100644
--- a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb
+++ b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb
@@ -61,7 +61,8 @@
            active: true,
            parent: parent_project,
            custom_field_values: {
-             project_custom_field_bool.id => true
+             project_custom_field_bool.id => true,
+             project_custom_field_long_text.id => "foo",
            },
            work_package_custom_fields: [cf_long_text, cf_disabled_in_project, cf_global_bool],
            work_package_custom_field_ids: [cf_long_text.id, cf_global_bool.id]) # cf_disabled_in_project.id is disabled
@@ -389,13 +390,13 @@ def get_column_value(column_name)
           "Custom field boolean", I18n.t(:general_text_Yes),
           "Custom field rich text", "[#{I18n.t('export.macro.rich_text_unsupported')}]",
           "Custom field hidden",
-          "[#{I18n.t('export.macro.error', message:
-            I18n.t('export.macro.resource_not_found', resource: "Secret string"))}]",
 
-          "No replacement of:", "projectValue:1:status", " ", "projectLabel:status",
+          "No replacement of:", "projectValue:1:status", "projectLabel:status",
           "projectValue:2:status projectLabel:status",
           "projectValue:3:status", "projectLabel:status",
 
+          "1", export_time_formatted, project.name,
+
           "Project not found:  ",
           "[#{I18n.t('export.macro.error', message:
             I18n.t('export.macro.resource_not_found', resource: "Project 1234567890"))}]  ",
@@ -403,7 +404,7 @@ def get_column_value(column_name)
           "[#{I18n.t('export.macro.error', message:
             I18n.t('export.macro.resource_not_found', resource: "Project #{forbidden_project.id}"))}]",
 
-          "1", export_time_formatted, project.name
+           "2", export_time_formatted, project.name,
         ].flatten
         expect(result.join(" ")).to eq(expected_result.join(" "))
       end

From 73aad7c16546d8e51049f63fdfc1efbb61af8917 Mon Sep 17 00:00:00 2001
From: as-op <a.sandorf@openproject.com>
Date: Wed, 5 Jun 2024 16:48:49 +0200
Subject: [PATCH 17/19] remove already checked formatter.applicable?

https://github.com/opf/openproject/pull/15702#discussion_r1627479843
---
 app/models/work_package/pdf_export/markdown_field.rb | 2 --
 1 file changed, 2 deletions(-)

diff --git a/app/models/work_package/pdf_export/markdown_field.rb b/app/models/work_package/pdf_export/markdown_field.rb
index ddb7648c8b0f..b478d14278ab 100644
--- a/app/models/work_package/pdf_export/markdown_field.rb
+++ b/app/models/work_package/pdf_export/markdown_field.rb
@@ -72,8 +72,6 @@ def apply_macro_text(text, work_package, formatter)
   end
 
   def apply_macro_html(html, work_package, formatter)
-    return html unless formatter.applicable?(html)
-
     doc = Nokogiri::HTML.fragment(html)
     apply_macro_html_node(doc, work_package, formatter)
     doc.to_html

From 53337f54c7857e7f1c222978925ead5188dc9c68 Mon Sep 17 00:00:00 2001
From: as-op <a.sandorf@openproject.com>
Date: Wed, 5 Jun 2024 17:38:06 +0200
Subject: [PATCH 18/19] support referencing project by identifier

https://github.com/opf/openproject/pull/15702#discussion_r1627668679
---
 app/models/work_package/exports/macros/attributes.rb |  1 +
 app/models/work_package/pdf_export/markdown_field.rb |  6 ++++--
 .../pdf_export/work_package_to_pdf_spec.rb           | 12 ++++++++++--
 3 files changed, 15 insertions(+), 4 deletions(-)

diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb
index fa979b5e4d51..4885c9ad355d 100644
--- a/app/models/work_package/exports/macros/attributes.rb
+++ b/app/models/work_package/exports/macros/attributes.rb
@@ -114,6 +114,7 @@ def self.resolve_project_match(id, type, attribute, user)
         return msg_macro_error(I18n.t("export.macro.model_not_found", model: type)) unless type == "value"
 
         project = Project.visible(user).find_by(id:)
+        project = Project.visible(user).find_by(identifier: id) if project.nil?
         if project.nil?
           return msg_macro_error(I18n.t("export.macro.resource_not_found", resource: "#{Project.name} #{id}"))
         end
diff --git a/app/models/work_package/pdf_export/markdown_field.rb b/app/models/work_package/pdf_export/markdown_field.rb
index b478d14278ab..f5d45baa1dd5 100644
--- a/app/models/work_package/pdf_export/markdown_field.rb
+++ b/app/models/work_package/pdf_export/markdown_field.rb
@@ -54,9 +54,9 @@ def apply_macros(markdown, work_package, formatter)
     document = Markly.parse(markdown)
     document.walk do |node|
       if node.type == :html
-        node.string_content = apply_macro_html(node.string_content, work_package, formatter)
+        node.string_content = apply_macro_html(node.string_content, work_package, formatter) || node.string_content
       elsif node.type == :text
-        node.string_content = apply_macro_text(node.string_content, work_package, formatter)
+        node.string_content = apply_macro_text(node.string_content, work_package, formatter) || node.string_content
       end
     end
     document.to_markdown
@@ -72,6 +72,8 @@ def apply_macro_text(text, work_package, formatter)
   end
 
   def apply_macro_html(html, work_package, formatter)
+    return html unless formatter.applicable?(html)
+
     doc = Nokogiri::HTML.fragment(html)
     apply_macro_html_node(doc, work_package, formatter)
     doc.to_html
diff --git a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb
index 9a703b1fb544..6078c2c63782 100644
--- a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb
+++ b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb
@@ -72,6 +72,7 @@
            name: "Forbidden project",
            types: [type],
            id: 666,
+           identifier: "forbidden-project",
            public: false,
            status_code: "on_track",
            active: true,
@@ -370,10 +371,15 @@ def get_column_value(column_name)
             projectLabel:status
             ```
 
+            Project by identifier:
+            projectValue:"#{project.identifier}":active
+
             Project not found:
             projectValue:1234567890:active
             Access denied:
             projectValue:#{forbidden_project.id}:active
+            Access denied by identifier:
+            projectValue:"#{forbidden_project.identifier}":active
         DESCRIPTION
       end
       it "contains resolved attributes and labels" do
@@ -397,14 +403,16 @@ def get_column_value(column_name)
 
           "1", export_time_formatted, project.name,
 
+          "Project by identifier:", " ", I18n.t(:general_text_Yes),
           "Project not found:  ",
           "[#{I18n.t('export.macro.error', message:
             I18n.t('export.macro.resource_not_found', resource: "Project 1234567890"))}]  ",
           "Access denied:  ",
           "[#{I18n.t('export.macro.error', message:
-            I18n.t('export.macro.resource_not_found', resource: "Project #{forbidden_project.id}"))}]",
+            I18n.t('export.macro.resource_not_found', resource: "Project #{forbidden_project.id}"))}]  ",
+          "Access denied by identifier:", " ", "[Macro error, resource not found: Project", "forbidden-project]",
 
-           "2", export_time_formatted, project.name,
+          "2", export_time_formatted, project.name,
         ].flatten
         expect(result.join(" ")).to eq(expected_result.join(" "))
       end

From 33afc826224f336b3fe4a97f462e410957397b21 Mon Sep 17 00:00:00 2001
From: as-op <a.sandorf@openproject.com>
Date: Wed, 5 Jun 2024 17:51:35 +0200
Subject: [PATCH 19/19] list more examples in comment

---
 .../work_package/exports/macros/attributes.rb  | 18 ++++++++++++++----
 1 file changed, 14 insertions(+), 4 deletions(-)

diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb
index 4885c9ad355d..8b50adfa503e 100644
--- a/app/models/work_package/exports/macros/attributes.rb
+++ b/app/models/work_package/exports/macros/attributes.rb
@@ -29,11 +29,21 @@ module WorkPackage::Exports
   module Macros
     # OpenProject attribute macros syntax
     # Examples:
-    #   workPackageLabel:1234:subject # Outputs work package label attribute "Subject" + help text
-    #   workPackageValue:1234:subject # Outputs the actual subject of #1234
+    #   workPackageLabel:subject # Outputs work package label attribute "Subject"
+    #   workPackageLabel:1234:subject # Outputs work package label attribute "Subject"
+
+    #   workPackageValue:subject # Outputs the subject of the current work package
+    #   workPackageValue:1234:subject # Outputs the subject of #1234
+    #   workPackageValue:"custom field name" # Outputs the custom field value of the current work package
+    #   workPackageValue:1234:"custom field name" # Outputs the custom field value of #1234
     #
-    #   projectLabel:statusExplanation # Outputs current project label attribute "Status description" + help text
-    #   projectValue:statusExplanation # Outputs current project value for "Status description"
+    #   projectLabel:active # Outputs current project label attribute "active"
+    #   projectLabel:1234:active # Outputs project label attribute "active"
+    #   projectLabel:my-project-identifier:active # Outputs project label attribute "active"
+
+    #   projectValue:active # Outputs current project value for "active"
+    #   projectValue:1234:active # Outputs project with id 1234 value for "active"
+    #   projectValue:my-project-identifier:active # Outputs project with identifier my-project-identifier value for "active"
     class Attributes < OpenProject::TextFormatting::Matchers::RegexMatcher
       DISABLED_PROJECT_RICH_TEXT_FIELDS = %i[description status_explanation status_description].freeze
       DISABLED_WORK_PACKAGE_RICH_TEXT_FIELDS = %i[description].freeze