diff --git a/app/services/pdf_generator_service.rb b/app/services/pdf_generator_service.rb index 080f83b7..c5e7eaed 100644 --- a/app/services/pdf_generator_service.rb +++ b/app/services/pdf_generator_service.rb @@ -4,83 +4,105 @@ class PdfGeneratorService include Configuration - def self.generate_inspection_report(inspection, debug_enabled: false, debug_queries: []) + def self.generate_inspection_report( + inspection, + debug_enabled: false, + debug_queries: [] + ) require "prawn/table" Prawn::Document.new(page_size: "A4", page_layout: :portrait) do |pdf| - Configuration.setup_pdf_fonts(pdf) - - # Initialize array to collect all assessment blocks - assessment_blocks = [] - - # Header section - HeaderGenerator.generate_inspection_pdf_header(pdf, inspection) - - # Unit details section - generate_inspection_unit_details(pdf, inspection) - - # Risk assessment section (if present) - generate_risk_assessment_section(pdf, inspection) - - # Generate all assessment sections in the correct UI order from applicable_tabs - generate_assessments_in_ui_order(inspection, assessment_blocks) - - # Render footer and photo first to measure actual space used - cursor_before_footer = pdf.cursor - - # Disclaimer footer (only on first page) - DisclaimerFooterRenderer.render_disclaimer_footer(pdf, inspection.user) - disclaimer_height = DisclaimerFooterRenderer.measure_footer_height(unbranded: false) - - # Add unit photo in bottom right corner - photo_height = ImageProcessor.measure_unit_photo_height(pdf, inspection.unit, 4) - ImageProcessor.add_unit_photo_footer(pdf, inspection.unit, 4) if inspection.unit&.photo - - # Reset cursor to render assessments with proper space accounting - pdf.move_cursor_to(cursor_before_footer) + generate_inspection_report_content( + pdf, + inspection, + debug_enabled, + debug_queries + ) + end + end - # Render all collected assessments in newspaper-style columns - render_assessment_blocks_in_columns(pdf, assessment_blocks, disclaimer_height, photo_height) + def self.generate_inspection_report_content( + pdf, + inspection, + debug_enabled, + debug_queries + ) + Configuration.setup_pdf_fonts(pdf) + assessment_blocks = [] + + generate_inspection_header_and_sections( + pdf, + inspection, + assessment_blocks + ) + footer_data = measure_and_render_footer_components(pdf, inspection) + render_assessment_blocks_in_columns( + pdf, + assessment_blocks, + footer_data[:disclaimer], + footer_data[:photo] + ) + + add_inspection_overlays_and_pages( + pdf, + inspection, + debug_enabled, + debug_queries + ) + end - # Add DRAFT watermark overlay for draft inspections (except in test env) - Utilities.add_draft_watermark(pdf) if !inspection.complete? && !Rails.env.test? + def self.generate_inspection_header_and_sections(pdf, inspection, assessment_blocks) + HeaderGenerator.generate_inspection_pdf_header(pdf, inspection) + generate_inspection_unit_details(pdf, inspection) + generate_risk_assessment_section(pdf, inspection) + generate_assessments_in_ui_order(inspection, assessment_blocks) + end - # Add photos page if photos are attached - PhotosRenderer.generate_photos_page(pdf, inspection) + def self.measure_and_render_footer_components(pdf, inspection) + cursor_before_footer = pdf.cursor + DisclaimerFooterRenderer.render_disclaimer_footer(pdf, inspection.user) + disclaimer_height = DisclaimerFooterRenderer.measure_footer_height(unbranded: false) + photo_height = ImageProcessor.measure_unit_photo_height(pdf, inspection.unit, 4) + ImageProcessor.add_unit_photo_footer(pdf, inspection.unit, 4) if inspection.unit&.photo + pdf.move_cursor_to(cursor_before_footer) + {disclaimer: disclaimer_height, photo: photo_height} + end - # Add debug info page if enabled (admins only) - DebugInfoRenderer.add_debug_info_page(pdf, debug_queries) if debug_enabled && debug_queries.present? - end + def self.add_inspection_overlays_and_pages(pdf, inspection, debug_enabled, debug_queries) + Utilities.add_draft_watermark(pdf) if !inspection.complete? && !Rails.env.test? + PhotosRenderer.generate_photos_page(pdf, inspection) + DebugInfoRenderer.add_debug_info_page(pdf, debug_queries) if debug_enabled && debug_queries.present? end def self.generate_unit_report(unit, debug_enabled: false, debug_queries: []) require "prawn/table" - unbranded = Rails.configuration.units.reports_unbranded - - # Preload all inspections once to avoid N+1 queries - completed_inspections = unit.inspections - .includes(:user, inspector_company: {logo_attachment: :blob}) - .complete - .order(inspection_date: :desc) - - last_inspection = completed_inspections.first + completed_inspections = load_completed_inspections(unit) Prawn::Document.new(page_size: "A4", page_layout: :portrait) do |pdf| Configuration.setup_pdf_fonts(pdf) - HeaderGenerator.generate_unit_pdf_header(pdf, unit, unbranded: unbranded) - generate_unit_details_with_inspection(pdf, unit, last_inspection) - generate_unit_inspection_history_with_data(pdf, unit, completed_inspections) + generate_unit_report_content(pdf, unit, completed_inspections, unbranded) + add_unit_footer_and_photo(pdf, unit, unbranded) + DebugInfoRenderer.add_debug_info_page(pdf, debug_queries) if debug_enabled && debug_queries.present? + end + end - # Disclaimer footer (only on first page, not for unbranded reports) - DisclaimerFooterRenderer.render_disclaimer_footer(pdf, unit.user, unbranded: unbranded) + def self.load_completed_inspections(unit) + unit.inspections + .includes(:user, inspector_company: {logo_attachment: :blob}) + .complete + .order(inspection_date: :desc) + end - # Add unit photo in bottom right corner (for unit PDFs, always use 3 columns) - ImageProcessor.add_unit_photo_footer(pdf, unit, 3) if unit.photo + def self.generate_unit_report_content(pdf, unit, completed_inspections, unbranded) + HeaderGenerator.generate_unit_pdf_header(pdf, unit, unbranded: unbranded) + generate_unit_details_with_inspection(pdf, unit, completed_inspections.first) + generate_unit_inspection_history_with_data(pdf, unit, completed_inspections) + end - # Add debug info page if enabled (admins only) - DebugInfoRenderer.add_debug_info_page(pdf, debug_queries) if debug_enabled && debug_queries.present? - end + def self.add_unit_footer_and_photo(pdf, unit, unbranded) + DisclaimerFooterRenderer.render_disclaimer_footer(pdf, unit.user, unbranded: unbranded) + ImageProcessor.add_unit_photo_footer(pdf, unit, 3) if unit.photo end def self.generate_inspection_unit_details(pdf, inspection) @@ -136,11 +158,14 @@ def self.generate_risk_assessment_section(pdf, inspection) pdf.stroke_horizontal_rule pdf.move_down 10 - # Create a text box constrained to 4 lines with shrink_to_fit - line_height = 10 * 1.2 # Normal font size * line height multiplier - max_height = line_height * 4 # 4 lines max + render_risk_assessment_text_box(pdf, inspection.risk_assessment) + end + + def self.render_risk_assessment_text_box(pdf, text) + line_height = 10 * 1.2 + max_height = line_height * 4 - pdf.text_box inspection.risk_assessment, + pdf.text_box text, at: [0, pdf.cursor], width: pdf.bounds.width, height: max_height, @@ -175,28 +200,20 @@ def self.render_assessment_blocks_in_columns(pdf, assessment_blocks, disclaimer_ pdf.stroke_horizontal_rule pdf.move_down 15 - # Calculate available height accounting for disclaimer footer only - available_height = if pdf.page_number == 1 - pdf.cursor - disclaimer_height - else - pdf.cursor - end - - # Check if we have enough space for at least some content - min_content_height = 100 # Minimum height for meaningful content - if available_height < min_content_height - pdf.start_new_page - available_height = pdf.cursor - 0 # No footer on new pages - end - - # Render assessments using the column layout with measured footer space + available_height = calculate_available_assessment_height(pdf, disclaimer_height) renderer = AssessmentColumns.new(assessment_blocks, available_height, photo_height) renderer.render(pdf) - pdf.move_down 20 end + def self.calculate_available_assessment_height(pdf, disclaimer_height) + available = (pdf.page_number == 1) ? (pdf.cursor - disclaimer_height) : pdf.cursor + return available if available >= 100 + + pdf.start_new_page + pdf.cursor + end + # Helper methods for backward compatibility and testing def self.truncate_text(text, max_length) Utilities.truncate_text(text, max_length) diff --git a/app/services/pdf_generator_service/assessment_block_builder.rb b/app/services/pdf_generator_service/assessment_block_builder.rb index 0ef4f361..026d591d 100644 --- a/app/services/pdf_generator_service/assessment_block_builder.rb +++ b/app/services/pdf_generator_service/assessment_block_builder.rb @@ -15,112 +15,111 @@ def initialize(assessment_type, assessment) end def build - blocks = [] + blocks = [create_header_block] + field_groups = group_assessment_fields(get_form_config_fields) + field_groups.each { |base, fields| process_field_group(blocks, fields) } + blocks + end - # Add header block - blocks << AssessmentBlock.new( + private + + def create_header_block + AssessmentBlock.new( type: :header, name: I18n.t("forms.#{@assessment_type}.header") ) + end - # Process fields - ordered_fields = get_form_config_fields - field_groups = group_assessment_fields(ordered_fields) + def process_field_group(blocks, fields) + main_field = fields[:base] || fields[:pass] + return if main_field && field_is_not_applicable?(main_field) - field_groups.each do |base, fields| - # Skip if this is a not-applicable field with value 0 - main_field = fields[:base] || fields[:pass] - if main_field && field_is_not_applicable?(main_field) - next - end + add_value_block(blocks, fields, main_field) if main_field + add_standalone_comment_label(blocks, fields) if fields[:comment] && !main_field + add_comment_block(blocks, fields) if fields[:comment] + end - # Add value block - if main_field - value = @assessment.send(main_field) - label = get_field_label(fields) - pass_value = determine_pass_value(fields, main_field, value) - is_pass_field = main_field.to_s.end_with?("_pass") - - # For boolean fields that aren't pass/fail fields - is_bool_non_pass = [true, false].include?(value) && - !is_pass_field && pass_value.nil? - blocks << if is_bool_non_pass - AssessmentBlock.new( - type: :value, - name: label, - value: value ? I18n.t("shared.yes") : I18n.t("shared.no") - ) - else - AssessmentBlock.new( - type: :value, - pass_fail: pass_value, - name: label, - value: is_pass_field ? nil : value - ) - end - elsif fields[:comment] - # Handle standalone comment fields (no base or pass field) - label = get_field_label(fields) - comment = @assessment.send(fields[:comment]) - if comment.present? - # Add a label block for the standalone comment - blocks << AssessmentBlock.new( - type: :value, - name: label, - value: nil - ) - end - end + def add_value_block(blocks, fields, main_field) + value = @assessment.send(main_field) + label = get_field_label(fields) + pass_value = determine_pass_value(fields, main_field, value) + is_pass_field = main_field.to_s.end_with?("_pass") + is_bool_non_pass = boolean_non_pass_field?(value, is_pass_field, pass_value) - # Add comment block if present - if fields[:comment] - comment = @assessment.send(fields[:comment]) - if comment.present? - blocks << AssessmentBlock.new( - type: :comment, - comment: comment - ) - end - end + blocks << if is_bool_non_pass + create_boolean_block(label, value) + else + create_standard_block(label, pass_value, is_pass_field, value) end + end - blocks + def boolean_non_pass_field?(value, is_pass_field, pass_value) + [true, false].include?(value) && !is_pass_field && pass_value.nil? end - private + def create_boolean_block(label, value) + AssessmentBlock.new( + type: :value, + name: label, + value: value ? I18n.t("shared.yes") : I18n.t("shared.no") + ) + end - def get_form_config_fields - return [] unless @assessment.class.respond_to?(:form_fields) + def create_standard_block(label, pass_value, is_pass_field, value) + AssessmentBlock.new( + type: :value, + pass_fail: pass_value, + name: label, + value: is_pass_field ? nil : value + ) + end - form_config = @assessment.class.form_fields - ordered_fields = [] + def add_standalone_comment_label(blocks, fields) + comment = @assessment.send(fields[:comment]) + return unless comment.present? - form_config.each do |section| - section[:fields].each do |field_config| - field_name = field_config[:field] - partial_name = field_config[:partial] + blocks << AssessmentBlock.new( + type: :value, + name: get_field_label(fields), + value: nil + ) + end - # Get composite fields first to check if any exist - composite_fields = ChobbleForms::FieldUtils.get_composite_fields(field_name, partial_name) + def add_comment_block(blocks, fields) + comment = @assessment.send(fields[:comment]) + return unless comment.present? - # Skip if neither the base field nor any composite fields exist - has_base = @assessment.respond_to?(field_name) - has_composites = composite_fields.any? { |cf| @assessment.respond_to?(cf) } - next unless has_base || has_composites + blocks << AssessmentBlock.new( + type: :comment, + comment: comment + ) + end - # Add base field if it exists - ordered_fields << field_name if has_base + def get_form_config_fields + return [] unless @assessment.class.respond_to?(:form_fields) - # Add composite fields that exist - composite_fields.each do |composite_field| - ordered_fields << composite_field if @assessment.respond_to?(composite_field) - end + ordered_fields = [] + @assessment.class.form_fields.each do |section| + section[:fields].each do |field_config| + add_field_and_composites(ordered_fields, field_config) end end - ordered_fields end + def add_field_and_composites(ordered_fields, field_config) + field_name = field_config[:field] + partial_name = field_config[:partial] + composite_fields = ChobbleForms::FieldUtils.get_composite_fields(field_name, partial_name) + + has_base = @assessment.respond_to?(field_name) + has_composites = composite_fields.any? { |cf| @assessment.respond_to?(cf) } + return unless has_base || has_composites + + ordered_fields << field_name if has_base + composite_fields.each { |cf| ordered_fields << cf if @assessment.respond_to?(cf) } + end + def group_assessment_fields(field_keys) field_keys.each_with_object({}) do |field, groups| field_str = field.to_s diff --git a/app/services/pdf_generator_service/assessment_block_renderer.rb b/app/services/pdf_generator_service/assessment_block_renderer.rb index 313478a0..ee832e15 100644 --- a/app/services/pdf_generator_service/assessment_block_renderer.rb +++ b/app/services/pdf_generator_service/assessment_block_renderer.rb @@ -39,34 +39,23 @@ def height_for(block, pdf) return 0 if fragments.empty? font_size = font_size_for(block) + formatted_text = convert_fragments_to_formatted_text(fragments) + base_height = pdf.height_of_formatted(formatted_text, width: COLUMN_WIDTH, size: font_size) + spacing = (font_size * 0.33).round(1) + base_height + spacing + end + + private - # Convert fragments to formatted text array - formatted_text = fragments.map do |fragment| + def convert_fragments_to_formatted_text(fragments) + fragments.map do |fragment| styles = [] styles << :bold if fragment[:bold] styles << :italic if fragment[:italic] - - { - text: fragment[:text], - styles: styles, - color: fragment[:color] - } + {text: fragment[:text], styles: styles, color: fragment[:color]} end - - # Use height_of_formatted to get the actual height with wrapping - base_height = pdf.height_of_formatted( - formatted_text, - width: COLUMN_WIDTH, - size: font_size - ) - - # Add 33% of font size as spacing - spacing = (font_size * 0.33).round(1) - base_height + spacing end - private - def render_header_fragments(block) text = block.name || block.value [{text: text, bold: true, color: "000000"}] @@ -74,28 +63,31 @@ def render_header_fragments(block) def render_value_fragments(block) fragments = [] + add_pass_fail_indicator(fragments, block) unless block.pass_fail.nil? + fragments << {text: block.name, bold: true, color: "000000"} if block.name + add_value_text(fragments, block) if should_add_value?(block) + fragments + end - # Add pass/fail indicator if present - if !block.pass_fail.nil? - indicator, color = case block.pass_fail - when true, "pass" then [I18n.t("shared.pass_pdf"), Configuration::PASS_COLOR] - when false, "fail" then [I18n.t("shared.fail_pdf"), Configuration::FAIL_COLOR] - else [I18n.t("shared.na_pdf"), Configuration::NA_COLOR] - end - fragments << {text: "#{indicator} ", bold: true, color: color} - end + def add_pass_fail_indicator(fragments, block) + indicator, color = determine_pass_fail_indicator(block.pass_fail) + fragments << {text: "#{indicator} ", bold: true, color: color} + end - # Add field name - if block.name - fragments << {text: block.name, bold: true, color: "000000"} + def determine_pass_fail_indicator(pass_fail) + case pass_fail + when true, "pass" then [I18n.t("shared.pass_pdf"), Configuration::PASS_COLOR] + when false, "fail" then [I18n.t("shared.fail_pdf"), Configuration::FAIL_COLOR] + else [I18n.t("shared.na_pdf"), Configuration::NA_COLOR] end + end - # Add value if present and not a pass/fail field - if block.value && !block.name.to_s.end_with?("_pass") - fragments << {text: ": #{block.value}", bold: false, color: "000000"} - end + def should_add_value?(block) + block.value && !block.name.to_s.end_with?("_pass") + end - fragments + def add_value_text(fragments, block) + fragments << {text: ": #{block.value}", bold: false, color: "000000"} end def render_comment_fragments(block) diff --git a/app/services/pdf_generator_service/assessment_columns.rb b/app/services/pdf_generator_service/assessment_columns.rb index 802cddf4..df569154 100644 --- a/app/services/pdf_generator_service/assessment_columns.rb +++ b/app/services/pdf_generator_service/assessment_columns.rb @@ -96,96 +96,81 @@ def place_content_in_columns(pdf, content_blocks, columns, start_y, font_size) column_y = start_y content_blocks.each do |content| - # Check if we need to move to next column - if current_column < columns.size - available = column_y - (start_y - columns[current_column][:height]) - - if available < content[:height] - # Move to next column - current_column += 1 - column_y = start_y - - # Stop if we run out of columns - break if current_column >= columns.size - end - else - break - end + break unless current_column < columns.size - # Render content in current column - column = columns[current_column] - render_content_at_position(pdf, content, column, column_y, font_size) + if needs_new_column?(column_y, start_y, columns[current_column], content[:height]) + current_column += 1 + column_y = start_y + break unless current_column < columns.size + end - # Update position + render_content_at_position(pdf, content, columns[current_column], column_y, font_size) column_y -= content[:height] end end + def needs_new_column?(column_y, start_y, column, content_height) + available = column_y - (start_y - column[:height]) + available < content_height + end + def render_content_at_position(pdf, content, column, y_pos, font_size) - # Save original state original_y = pdf.cursor original_fill_color = pdf.fill_color - # Calculate actual x position - actual_x = column[:x] - - # Use the font size from the content block if available text_size = content[:font_size] || font_size + formatted_text = convert_content_fragments_to_formatted_text(content[:fragments]) - # Convert fragments to formatted text array for proper wrapping - formatted_text = content[:fragments].map do |fragment| - styles = [] - styles << :bold if fragment[:bold] - styles << :italic if fragment[:italic] - - { - text: fragment[:text], - styles: styles, - color: fragment[:color] - } - end - - # Render as single formatted text box for proper wrapping pdf.formatted_text_box( formatted_text, - at: [actual_x, y_pos], + at: [column[:x], y_pos], width: column[:width], size: text_size, overflow: :truncate ) - # Restore original state pdf.fill_color original_fill_color pdf.move_cursor_to original_y end + def convert_content_fragments_to_formatted_text(fragments) + fragments.map do |fragment| + styles = [] + styles << :bold if fragment[:bold] + styles << :italic if fragment[:italic] + {text: fragment[:text], styles: styles, color: fragment[:color]} + end + end + def calculate_column_boxes(pdf) total_spacer_width = Configuration::ASSESSMENT_COLUMN_SPACER * 3 column_width = (pdf.bounds.width - total_spacer_width) / 4.0 - columns = [] - # First three columns - full height - 3.times do |i| - x = i * (column_width + Configuration::ASSESSMENT_COLUMN_SPACER) - columns << { - x: x, - y: pdf.cursor, - width: column_width, - height: assessment_results_height - } - end + 3.times { |i| columns << create_full_height_column(i, column_width, pdf) } + columns << create_fourth_column(column_width, pdf) + columns + end - # Fourth column - reduced by photo height - fourth_column_height = [assessment_results_height - photo_height - 5, 0].max # 5pt buffer - columns << { - x: 3 * (column_width + Configuration::ASSESSMENT_COLUMN_SPACER), + def create_full_height_column(index, column_width, pdf) + spacer = Configuration::ASSESSMENT_COLUMN_SPACER + { + x: index * (column_width + spacer), y: pdf.cursor, width: column_width, - height: fourth_column_height + height: assessment_results_height } + end - columns + def create_fourth_column(column_width, pdf) + fourth_column_height = [assessment_results_height - photo_height - 5, 0].max + spacer = Configuration::ASSESSMENT_COLUMN_SPACER + { + x: 3 * (column_width + spacer), + y: pdf.cursor, + width: column_width, + height: fourth_column_height + } end def calculate_line_height(font_size) diff --git a/app/services/pdf_generator_service/debug_info_renderer.rb b/app/services/pdf_generator_service/debug_info_renderer.rb index b2227b53..e01e2cbf 100644 --- a/app/services/pdf_generator_service/debug_info_renderer.rb +++ b/app/services/pdf_generator_service/debug_info_renderer.rb @@ -7,57 +7,67 @@ class DebugInfoRenderer def self.add_debug_info_page(pdf, queries) return if queries.blank? - # Start a new page for debug info pdf.start_new_page + render_debug_header(pdf) + render_debug_summary(pdf, queries) + render_debug_table(pdf, queries) + end - # Header + def self.render_debug_header(pdf) pdf.text I18n.t("debug.title"), size: HEADER_TEXT_SIZE, style: :bold pdf.stroke_horizontal_rule pdf.move_down 10 + end - # Summary info + def self.render_debug_summary(pdf, queries) total_rows = queries.sum { |q| q[:row_count] || 0 } pdf.text "#{I18n.t("debug.query_count")}: #{queries.size}", size: NICE_TABLE_TEXT_SIZE pdf.text "#{I18n.t("debug.total_rows")}: #{total_rows}", size: NICE_TABLE_TEXT_SIZE pdf.move_down 10 + end - # Build table data - table_data = [ - [I18n.t("debug.query"), I18n.t("debug.duration"), I18n.t("debug.rows"), I18n.t("debug.name")] - ] + def self.render_debug_table(pdf, queries) + table_data = build_debug_table_data(queries) + pdf.table(table_data, width: pdf.bounds.width) { |t| style_debug_table(t, table_data) } + end - queries.each do |query| - table_data << [ - query[:sql], - "#{query[:duration]} ms", - query[:row_count] || 0, - query[:name] || "" - ] - end + def self.build_debug_table_data(queries) + header = [I18n.t("debug.query"), I18n.t("debug.duration"), I18n.t("debug.rows"), I18n.t("debug.name")] + rows = queries.map { |q| [q[:sql], "#{q[:duration]} ms", q[:row_count] || 0, q[:name] || ""] } + [header] + rows + end - # Create the table - pdf.table(table_data, width: pdf.bounds.width) do |t| - # Header row styling - t.row(0).background_color = "333333" - t.row(0).text_color = "FFFFFF" - t.row(0).font_style = :bold + def self.style_debug_table(table, table_data) + style_debug_table_header(table) + style_debug_table_cells(table) + apply_debug_column_widths(table) + apply_debug_row_colors(table, table_data.length) + end - # General styling - t.cells.borders = [:bottom] - t.cells.border_color = "DDDDDD" - t.cells.padding = TABLE_CELL_PADDING - t.cells.size = 8 + def self.style_debug_table_header(table) + table.row(0).background_color = "333333" + table.row(0).text_color = "FFFFFF" + table.row(0).font_style = :bold + end - # Column widths - t.columns(0).width = pdf.bounds.width * 0.5 # Query column gets most space - t.columns(1).width = pdf.bounds.width * 0.15 # Duration - t.columns(2).width = pdf.bounds.width * 0.1 # Rows - t.columns(3).width = pdf.bounds.width * 0.25 # Name + def self.style_debug_table_cells(table) + table.cells.borders = [:bottom] + table.cells.border_color = "DDDDDD" + table.cells.padding = TABLE_CELL_PADDING + table.cells.size = 8 + end + + def self.apply_debug_column_widths(table) + pdf_width = table.cells.column(0).first.parent.width + table.columns(0).width = pdf_width * 0.5 + table.columns(1).width = pdf_width * 0.15 + table.columns(2).width = pdf_width * 0.1 + table.columns(3).width = pdf_width * 0.25 + end - # Alternating row colors - (1..table_data.length - 1).each do |i| - t.row(i).background_color = i.odd? ? "FFFFFF" : "F5F5F5" - end + def self.apply_debug_row_colors(table, row_count) + (1..row_count - 1).each do |i| + table.row(i).background_color = i.odd? ? "FFFFFF" : "F5F5F5" end end end diff --git a/app/services/pdf_generator_service/disclaimer_footer_renderer.rb b/app/services/pdf_generator_service/disclaimer_footer_renderer.rb index c5d3a87c..37181b85 100644 --- a/app/services/pdf_generator_service/disclaimer_footer_renderer.rb +++ b/app/services/pdf_generator_service/disclaimer_footer_renderer.rb @@ -8,26 +8,22 @@ class DisclaimerFooterRenderer def self.render_disclaimer_footer(pdf, user, unbranded: false) return if unbranded - # Save current position original_y = pdf.cursor + pdf.move_cursor_to FOOTER_HEIGHT - # Move to footer position - footer_y = FOOTER_HEIGHT - pdf.move_cursor_to footer_y - - # Create bounding box for footer - bounding_box_width = pdf.bounds.width - bounding_box_at = [0, pdf.cursor] - pdf.bounding_box(bounding_box_at, - width: bounding_box_width, - height: FOOTER_HEIGHT) do - # Add top padding + render_footer_in_bounding_box(pdf, user) + pdf.move_cursor_to original_y + end + + def self.render_footer_in_bounding_box(pdf, user) + pdf.bounding_box( + [0, pdf.cursor], + width: pdf.bounds.width, + height: FOOTER_HEIGHT + ) do pdf.move_down FOOTER_TOP_PADDING render_footer_content(pdf, user) end - - # Restore position - pdf.move_cursor_to original_y end def self.measure_footer_height(unbranded:) @@ -35,76 +31,69 @@ def self.measure_footer_height(unbranded:) end def self.render_footer_content(pdf, user) - # Render disclaimer header render_disclaimer_header(pdf) - pdf.move_down FOOTER_INTERNAL_PADDING - # Check what content we have has_signature = user&.signature&.attached? - pdf_logo = Rails.configuration.pdf.logo - has_user_logo = pdf_logo.present? && user&.logo&.attached? - pdf.bounds.width - - first_row = [ - pdf.make_cell( - content: I18n.t("pdf.disclaimer.text"), - size: DISCLAIMER_TEXT_SIZE, - inline_format: true, - valign: :top, - padding: [0, (has_signature || has_user_logo) ? 10 : 0, 0, 0] - ) - ] - - if has_signature - first_row << pdf.make_cell( - image: StringIO.new(user.signature.download), - fit: [100, DISCLAIMER_TEXT_HEIGHT], - width: 100, - borders: %i[top bottom left right], - border_color: "CCCCCC", - border_width: 1, - padding: 5, - padding_right: has_user_logo ? 10 : 5, - padding_left: 5 - ) - end + has_user_logo = check_has_user_logo(user) - if has_user_logo - first_row << pdf.make_cell( - image: StringIO.new(user.logo.download), - fit: [1000, DISCLAIMER_TEXT_HEIGHT], - borders: [], - padding: [0, 0, 0, 10] - ) - end + first_row = build_footer_first_row(pdf, user, has_signature, has_user_logo) + table_data = [first_row] - if has_signature - caption_row = [pdf.make_cell(content: "", borders: [], padding: 0)] - caption_row << pdf.make_cell( - content: I18n.t("pdf.signature.caption"), - size: DISCLAIMER_TEXT_SIZE, - align: :center, - borders: [], - padding: [5, has_user_logo ? 10 : 5, 0, 5] - ) - caption_row << pdf.make_cell(content: "", borders: [], padding: 0) if has_user_logo - end + pdf.table(table_data) { |t| t.cells.borders = [] } + end - first_row.length + def self.check_has_user_logo(user) + pdf_logo = Rails.configuration.pdf.logo + pdf_logo.present? && user&.logo&.attached? + end - table_data = [first_row] - # table_data << caption_row if has_signature + def self.build_footer_first_row(pdf, user, has_signature, has_user_logo) + first_row = [build_disclaimer_cell(pdf, has_signature, has_user_logo)] + first_row << build_signature_cell(pdf, user, has_user_logo) if has_signature + first_row << build_logo_cell(pdf, user) if has_user_logo + first_row + end - pdf.table(table_data) do |t| - t.cells.borders = [] - end + def self.build_disclaimer_cell(pdf, has_signature, has_user_logo) + pdf.make_cell( + content: I18n.t("pdf.disclaimer.text"), + size: DISCLAIMER_TEXT_SIZE, + inline_format: true, + valign: :top, + padding: [0, (has_signature || has_user_logo) ? 10 : 0, 0, 0] + ) + end + + def self.build_signature_cell(pdf, user, has_user_logo) + pdf.make_cell( + image: StringIO.new(user.signature.download), + fit: [100, DISCLAIMER_TEXT_HEIGHT], + width: 100, + borders: %i[top bottom left right], + border_color: "CCCCCC", + border_width: 1, + padding: 5, + padding_right: has_user_logo ? 10 : 5, + padding_left: 5 + ) + end + + def self.build_logo_cell(pdf, user) + pdf.make_cell( + image: StringIO.new(user.logo.download), + fit: [1000, DISCLAIMER_TEXT_HEIGHT], + borders: [], + padding: [0, 0, 0, 10] + ) end def self.render_disclaimer_header(pdf) - pdf.text I18n.t("pdf.disclaimer.header"), + pdf.text( + I18n.t("pdf.disclaimer.header"), size: DISCLAIMER_HEADER_SIZE, style: :bold + ) pdf.stroke_horizontal_rule end end diff --git a/app/services/pdf_generator_service/header_generator.rb b/app/services/pdf_generator_service/header_generator.rb index afdee8f9..facacb63 100644 --- a/app/services/pdf_generator_service/header_generator.rb +++ b/app/services/pdf_generator_service/header_generator.rb @@ -93,53 +93,97 @@ def prepare_logo(user) [logo_width, logo_data, user.logo] end - def render_inspection_text_section(pdf, inspection, report_id_text, - status_text, status_color, logo_width) - # Shift text to the right to accommodate QR code + def render_inspection_text_section( + pdf, + inspection, + report_id_text, + status_text, + status_color, + logo_width + ) qr_offset = Configuration::QR_CODE_SIZE + Configuration::HEADER_SPACING width = pdf.bounds.width - logo_width - qr_offset pdf.bounding_box([qr_offset, pdf.bounds.top], width: width) do - pdf.text report_id_text, size: Configuration::HEADER_TEXT_SIZE, - style: :bold - pdf.text status_text, size: Configuration::HEADER_TEXT_SIZE, - style: :bold, - color: status_color - - expiry_label = I18n.t("pdf.inspection.fields.expiry_date") - expiry_value = Utilities.format_date(inspection.reinspection_date) - pdf.text "#{expiry_label}: #{expiry_value}", - size: Configuration::HEADER_TEXT_SIZE, style: :bold + render_inspection_report_id(pdf, report_id_text) + render_inspection_status(pdf, status_text, status_color) + render_inspection_expiry(pdf, inspection) end end + def render_inspection_report_id(pdf, report_id_text) + pdf.text( + report_id_text, + size: Configuration::HEADER_TEXT_SIZE, + style: :bold + ) + end + + def render_inspection_status(pdf, status_text, status_color) + pdf.text( + status_text, + size: Configuration::HEADER_TEXT_SIZE, + style: :bold, + color: status_color + ) + end + + def render_inspection_expiry(pdf, inspection) + expiry_label = I18n.t("pdf.inspection.fields.expiry_date") + expiry_value = Utilities.format_date(inspection.reinspection_date) + pdf.text( + "#{expiry_label}: #{expiry_value}", + size: Configuration::HEADER_TEXT_SIZE, + style: :bold + ) + end + def render_unit_text_section(pdf, unit, unit_id_text, logo_width) - # Shift text to the right to accommodate QR code qr_offset = Configuration::QR_CODE_SIZE + Configuration::HEADER_SPACING width = pdf.bounds.width - logo_width - qr_offset pdf.bounding_box([qr_offset, pdf.bounds.top], width: width) do - pdf.text unit_id_text, size: Configuration::HEADER_TEXT_SIZE, - style: :bold - - expiry_label = I18n.t("pdf.unit.fields.expiry_date") - expiry_value = if unit.last_inspection&.reinspection_date - Utilities.format_date(unit.last_inspection.reinspection_date) - else - I18n.t("pdf.unit.fields.na") - end - pdf.text "#{expiry_label}: #{expiry_value}", - size: Configuration::HEADER_TEXT_SIZE, style: :bold - - # Add extra line of spacing to match 3-line QR code height + render_unit_header_text(pdf, unit_id_text) + render_unit_expiry_text(pdf, unit) pdf.move_down Configuration::HEADER_TEXT_SIZE * 1.5 end end + def render_unit_header_text(pdf, unit_id_text) + pdf.text( + unit_id_text, + size: Configuration::HEADER_TEXT_SIZE, + style: :bold + ) + end + + def render_unit_expiry_text(pdf, unit) + expiry_label = I18n.t("pdf.unit.fields.expiry_date") + expiry_value = format_unit_expiry_value(unit) + pdf.text( + "#{expiry_label}: #{expiry_value}", + size: Configuration::HEADER_TEXT_SIZE, + style: :bold + ) + end + + def format_unit_expiry_value(unit) + if unit.last_inspection&.reinspection_date + Utilities.format_date(unit.last_inspection.reinspection_date) + else + I18n.t("pdf.unit.fields.na") + end + end + def render_logo_section(pdf, logo_data, logo_width, logo_attachment) x_position = pdf.bounds.width - logo_width + 10 - pdf.bounding_box([x_position, pdf.bounds.top], - width: logo_width - 10) do - pdf.image StringIO.new(logo_data), height: Configuration::LOGO_HEIGHT, + pdf.bounding_box( + [x_position, pdf.bounds.top], + width: logo_width - 10 + ) do + pdf.image( + StringIO.new(logo_data), + height: Configuration::LOGO_HEIGHT, position: :right + ) end rescue Prawn::Errors::UnsupportedImageType => e raise ImageError.build_detailed_error(e, logo_attachment) diff --git a/app/services/pdf_generator_service/image_processor.rb b/app/services/pdf_generator_service/image_processor.rb index 82fa0e11..9a9dcdec 100644 --- a/app/services/pdf_generator_service/image_processor.rb +++ b/app/services/pdf_generator_service/image_processor.rb @@ -21,26 +21,43 @@ def self.generate_qr_code_header(pdf, entity) def self.add_unit_photo_footer(pdf, unit, column_count = 3) return unless unit&.photo&.blob - # Calculate photo position in bottom right corner - pdf_width = pdf.bounds.width - - # Calculate photo dimensions based on column count attachment = unit.photo image = create_image(attachment) dimensions = calculate_footer_photo_dimensions(pdf, image, column_count) photo_width, photo_height = dimensions - # Position photo in bottom right corner - photo_x = pdf_width - photo_width - # Account for footer height on first page - photo_y = calculate_photo_y(pdf, photo_height) - - render_processed_image(pdf, image, photo_x, photo_y, - photo_width, photo_height, attachment) + position_and_render_photo( + pdf, + image, + photo_width, + photo_height, + attachment + ) rescue Prawn::Errors::UnsupportedImageType => e raise ImageError.build_detailed_error(e, attachment) end + def self.position_and_render_photo( + pdf, + image, + photo_width, + photo_height, + attachment + ) + photo_x = pdf.bounds.width - photo_width + photo_y = calculate_photo_y(pdf, photo_height) + + render_processed_image( + pdf, + image, + photo_x, + photo_y, + photo_width, + photo_height, + attachment + ) + end + def self.measure_unit_photo_height(pdf, unit, column_count = 3) return 0 unless unit&.photo&.blob @@ -65,28 +82,31 @@ def self.process_image_with_orientation(attachment) end def self.calculate_footer_photo_dimensions(pdf, image, column_count = 3) - original_width = image.width - original_height = image.height + photo_width = calculate_column_width(pdf.bounds.width, column_count) + photo_height = calculate_photo_height( + image.width, + image.height, + photo_width + ) + [photo_width, photo_height] + end - # Calculate column width based on PDF width and column count - # Account for column spacers + def self.calculate_column_width(pdf_width, column_count) spacer_count = column_count - 1 spacer_width = Configuration::ASSESSMENT_COLUMN_SPACER total_spacer_width = spacer_width * spacer_count - column_width = (pdf.bounds.width - total_spacer_width) / column_count.to_f + ((pdf_width - total_spacer_width) / column_count.to_f).round + end - # Photo width equals one column width - photo_width = column_width.round + def self.calculate_photo_height( + original_width, + original_height, + photo_width + ) + return photo_width if original_width.zero? || original_height.zero? - # Calculate height maintaining aspect ratio - if original_width.zero? || original_height.zero? - photo_height = photo_width - else - aspect_ratio = original_width.to_f / original_height.to_f - photo_height = (photo_width / aspect_ratio).round - end - - [photo_width, photo_height] + aspect_ratio = original_width.to_f / original_height.to_f + (photo_width / aspect_ratio).round end def self.render_processed_image(pdf, image, x, y, width, height, attachment) diff --git a/app/services/pdf_generator_service/position_calculator.rb b/app/services/pdf_generator_service/position_calculator.rb index 68b88f1e..8201ede3 100644 --- a/app/services/pdf_generator_service/position_calculator.rb +++ b/app/services/pdf_generator_service/position_calculator.rb @@ -67,22 +67,22 @@ def self.calculate_aspect_ratio(original_width, original_height) original_width.to_f / original_height.to_f end - # Calculate dimensions to fit within constraints while maintaining aspect ratio def self.fit_dimensions(original_width, original_height, max_width, max_height) return [max_width, max_height] if original_width.zero? || original_height.zero? - - # If original already fits within constraints, return original dimensions - if original_width <= max_width && original_height <= max_height - return [original_width, original_height] - end + return [original_width, original_height] if fits_within_bounds?(original_width, original_height, max_width, max_height) aspect_ratio = calculate_aspect_ratio(original_width, original_height) + fit_to_constraints(aspect_ratio, max_width, max_height) + end + + def self.fits_within_bounds?(width, height, max_width, max_height) + width <= max_width && height <= max_height + end - # Try fitting by width first + def self.fit_to_constraints(aspect_ratio, max_width, max_height) fitted_width = max_width fitted_height = (fitted_width / aspect_ratio).round - # If height is too big, fit by height instead if fitted_height > max_height fitted_height = max_height fitted_width = (fitted_height * aspect_ratio).round diff --git a/app/services/pdf_generator_service/table_builder.rb b/app/services/pdf_generator_service/table_builder.rb index c8e2d868..8c93a396 100644 --- a/app/services/pdf_generator_service/table_builder.rb +++ b/app/services/pdf_generator_service/table_builder.rb @@ -212,22 +212,8 @@ def self.build_unit_details_table(unit, context) end def self.build_unit_details_table_for_unit_pdf(unit, last_inspection) - dimensions = [] - - if last_inspection - if last_inspection.width.present? - dimensions << "#{ChobbleForms::FieldUtils.form_field_label(:inspection, :width).sub(" (m)", "")}: #{Utilities.format_dimension(last_inspection.width)}" - end - if last_inspection.length.present? - dimensions << "#{ChobbleForms::FieldUtils.form_field_label(:inspection, :length).sub(" (m)", "")}: #{Utilities.format_dimension(last_inspection.length)}" - end - if last_inspection.height.present? - dimensions << "#{ChobbleForms::FieldUtils.form_field_label(:inspection, :height).sub(" (m)", "")}: #{Utilities.format_dimension(last_inspection.height)}" - end - end - dimensions_text = dimensions.any? ? dimensions.join(" ") : "" + dimensions_text = build_dimensions_text(last_inspection) - # Build simple two-column table for unit PDFs [ [ChobbleForms::FieldUtils.form_field_label(:units, :name), Utilities.truncate_text(unit.name, UNIT_NAME_MAX_LENGTH)], @@ -239,42 +225,57 @@ def self.build_unit_details_table_for_unit_pdf(unit, last_inspection) end def self.build_unit_details_table_with_inspection(unit, last_inspection, context) + dimensions_text = build_dimensions_text(last_inspection) + inspection = select_inspection_for_context(last_inspection, unit, context) + inspector_text = build_inspector_text(inspection) + issued_date = format_issued_date(inspection) + + build_four_column_table_rows(unit, dimensions_text, inspector_text, issued_date) + end + + def self.build_dimensions_text(inspection) + dimensions = collect_dimension_strings(inspection) + dimensions.any? ? dimensions.join(" ") : "" + end + + def self.collect_dimension_strings(inspection) + return [] unless inspection + dimensions = [] + add_dimension(dimensions, inspection.width, :width) if inspection.width.present? + add_dimension(dimensions, inspection.length, :length) if inspection.length.present? + add_dimension(dimensions, inspection.height, :height) if inspection.height.present? + dimensions + end - if last_inspection - if last_inspection.width.present? - dimensions << "#{ChobbleForms::FieldUtils.form_field_label(:inspection, :width).sub(" (m)", "")}: #{Utilities.format_dimension(last_inspection.width)}" - end - if last_inspection.length.present? - dimensions << "#{ChobbleForms::FieldUtils.form_field_label(:inspection, :length).sub(" (m)", "")}: #{Utilities.format_dimension(last_inspection.length)}" - end - if last_inspection.height.present? - dimensions << "#{ChobbleForms::FieldUtils.form_field_label(:inspection, :height).sub(" (m)", "")}: #{Utilities.format_dimension(last_inspection.height)}" - end - end - dimensions_text = dimensions.any? ? dimensions.join(" ") : "" + def self.add_dimension(dimensions, value, field) + label = ChobbleForms::FieldUtils.form_field_label(:inspection, field).sub(" (m)", "") + formatted_value = Utilities.format_dimension(value) + dimensions << "#{label}: #{formatted_value}" + end - # Get inspector details from current inspection (for inspection PDF) or last inspection (for unit PDF) - inspection = if context == :inspection - last_inspection - else - unit.last_inspection - end + def self.select_inspection_for_context(last_inspection, unit, context) + context == :inspection ? last_inspection : unit.last_inspection + end + + def self.build_inspector_text(inspection) inspector_name = inspection&.user&.name rpii_number = inspection&.user&.rpii_inspector_number - # Combine inspector name with RPII number if present - inspector_text = if rpii_number.present? - "#{inspector_name} (#{I18n.t("pdf.inspection.fields.rpii_inspector_no")} #{rpii_number})" + if rpii_number.present? + rpii_label = I18n.t("pdf.inspection.fields.rpii_inspector_no") + "#{inspector_name} (#{rpii_label} #{rpii_number})" else inspector_name end + end - issued_date = if inspection&.inspection_date - Utilities.format_date(inspection.inspection_date) - end + def self.format_issued_date(inspection) + return nil unless inspection&.inspection_date + Utilities.format_date(inspection.inspection_date) + end - # Build the table rows + def self.build_four_column_table_rows(unit, dimensions_text, inspector_text, issued_date) [ [ ChobbleForms::FieldUtils.form_field_label(:units, :name), diff --git a/app/services/pdf_generator_service/utilities.rb b/app/services/pdf_generator_service/utilities.rb index d49edcd0..b2a50825 100644 --- a/app/services/pdf_generator_service/utilities.rb +++ b/app/services/pdf_generator_service/utilities.rb @@ -16,7 +16,7 @@ def self.format_dimension(value) def self.format_date(date) return I18n.t("pdf.inspection.fields.na") if date.nil? - date.strftime("%-d %B, %Y") + date.strftime(I18n.t("date.formats.pdf")) end def self.format_pass_fail(value) @@ -33,33 +33,49 @@ def self.format_measurement(value, unit = "") end def self.add_draft_watermark(pdf) - # Add 3x3 grid of DRAFT watermarks to each page (1..pdf.page_count).each do |page_num| pdf.go_to_page(page_num) + render_watermark_grid(pdf) + pdf.fill_color "000000" + end + end + + def self.render_watermark_grid(pdf) + pdf.transparent(WATERMARK_TRANSPARENCY) do + pdf.fill_color "FF0000" + y_positions = calculate_watermark_y_positions(pdf) + x_positions = calculate_watermark_x_positions(pdf) + render_watermark_at_positions(pdf, y_positions, x_positions) + end + end - pdf.transparent(WATERMARK_TRANSPARENCY) do - pdf.fill_color "FF0000" + def self.calculate_watermark_y_positions(pdf) + [0.10, 0.30, 0.50, 0.70, 0.9].map { pdf.bounds.height * _1 } + end - # 3x3 grid positions - y_positions = [0.10, 0.30, 0.50, 0.70, 0.9].map { |pct| pdf.bounds.height * pct } - x_positions = [0.15, 0.50, 0.85].map { |pct| pdf.bounds.width * pct - (WATERMARK_WIDTH / 2) } + def self.calculate_watermark_x_positions(pdf) + [0.15, 0.50, 0.85].map { pdf.bounds.width * _1 - (WATERMARK_WIDTH / 2) } + end - y_positions.each do |y| - x_positions.each do |x| - pdf.text_box I18n.t("pdf.inspection.watermark.draft"), - at: [x, y], - width: WATERMARK_WIDTH, - height: WATERMARK_HEIGHT, - size: WATERMARK_TEXT_SIZE, - style: :bold, - align: :center, - valign: :top - end - end + def self.render_watermark_at_positions(pdf, y_positions, x_positions) + y_positions.each do |y| + x_positions.each do |x| + render_watermark_text(pdf, x, y) end - - pdf.fill_color "000000" end end + + def self.render_watermark_text(pdf, x, y) + pdf.text_box( + I18n.t("pdf.inspection.watermark.draft"), + at: [x, y], + width: WATERMARK_WIDTH, + height: WATERMARK_HEIGHT, + size: WATERMARK_TEXT_SIZE, + style: :bold, + align: :center, + valign: :top + ) + end end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 1380d554..4580a48f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -19,6 +19,7 @@ en: default: '%Y-%m-%d' short: '%b %d' long: '%B %d, %Y' + pdf: '%-d %B, %Y' time: formats: default: '%a, %d %b %Y %H:%M:%S %z' diff --git a/spec/services/pdf_generator_service/disclaimer_footer_renderer_spec.rb b/spec/services/pdf_generator_service/disclaimer_footer_renderer_spec.rb index 30b1eda2..18c17dd0 100644 --- a/spec/services/pdf_generator_service/disclaimer_footer_renderer_spec.rb +++ b/spec/services/pdf_generator_service/disclaimer_footer_renderer_spec.rb @@ -74,8 +74,7 @@ allow(user).to receive(:logo).and_return( double("logo", attached?: false) ) - allow(ENV).to receive(:[]).and_call_original - allow(ENV).to receive(:[]).with("PDF_LOGO").and_return(nil) + allow(Rails.configuration.pdf).to receive(:logo).and_return(nil) end it "renders disclaimer header" do @@ -108,8 +107,7 @@ allow(user).to receive(:logo).and_return( double("logo", attached?: false) ) - allow(ENV).to receive(:[]).and_call_original - allow(ENV).to receive(:[]).with("PDF_LOGO").and_return(nil) + allow(Rails.configuration.pdf).to receive(:logo).and_return(nil) end it "creates table with disclaimer and signature cells" do @@ -125,8 +123,7 @@ double("signature", attached?: false) ) allow(user).to receive(:logo).and_return(logo) - allow(ENV).to receive(:[]).and_call_original - allow(ENV).to receive(:[]).with("PDF_LOGO").and_return("true") + allow(Rails.configuration.pdf).to receive(:logo).and_return("logo.png") end it "creates table with disclaimer and logo cells" do @@ -140,8 +137,7 @@ before do allow(user).to receive(:signature).and_return(signature) allow(user).to receive(:logo).and_return(logo) - allow(ENV).to receive(:[]).and_call_original - allow(ENV).to receive(:[]).with("PDF_LOGO").and_return("true") + allow(Rails.configuration.pdf).to receive(:logo).and_return("logo.png") end it "creates table with all three cells" do