diff --git a/app/controllers/public/resources_controller.rb b/app/controllers/public/resources_controller.rb index fa99e50..f692a64 100644 --- a/app/controllers/public/resources_controller.rb +++ b/app/controllers/public/resources_controller.rb @@ -25,7 +25,13 @@ def iiif page_number = params[:page] || 1 redirect_resource do |resource| - resource.image? ? resource.content_converted_iiif_url(page_number) : resource.content_iiif_url(page_number) + if resource.converted_pages? + resource.content_converted_pages_iiif_url(page_number.to_i) + elsif resource.image? + resource.content_converted_iiif_url(page_number) + else + resource.content_iiif_url(page_number) + end end end @@ -33,14 +39,25 @@ def image_api page_number = params[:page] || 1 redirect_resource do |resource| - resource.content_image_api_url( - page_number, - params[:region], - params[:size], - params[:rotation], - params[:quality], - params[:format] - ) + if resource.converted_pages? + resource.content_converted_pages_image_api_url( + page_number, + params[:region], + params[:size], + params[:rotation], + params[:quality], + params[:format] + ) + else + resource.content_image_api_url( + page_number, + params[:region], + params[:size], + params[:rotation], + params[:quality], + params[:format] + ) + end end end @@ -48,7 +65,13 @@ def info page_number = params[:page] || 1 redirect_resource do |resource| - resource.image? ? resource.content_converted_info_url(page_number) : resource.content_info_url(page_number) + if resource.converted_pages? + resource.content_converted_pages_info_url(page_number) + elsif resource.image? + resource.content_converted_info_url(page_number) + else + resource.content_info_url(page_number) + end end end @@ -64,14 +87,30 @@ def manifest end def preview + page_number = params[:page] || 1 + redirect_resource do |resource| - resource.image? ? resource.content_converted_preview_url : resource.content_preview_url + if resource.converted_pages? + resource.content_converted_pages_preview_url(page_number) + elsif resource.image? + resource.content_converted_preview_url + else + resource.content_preview_url + end end end def thumbnail + page_number = params[:page] || 1 + redirect_resource do |resource| - resource.image? ? resource.content_converted_thumbnail_url : resource.content_thumbnail_url + if resource.converted_pages? + resource.content_converted_pages_thumbnail_url(page_number) + elsif resource.image? + resource.content_converted_thumbnail_url + else + resource.content_thumbnail_url + end end end diff --git a/app/jobs/convert_image_job.rb b/app/jobs/convert_image_job.rb index 12dc8be..009c6a3 100644 --- a/app/jobs/convert_image_job.rb +++ b/app/jobs/convert_image_job.rb @@ -5,35 +5,120 @@ class ConvertImageJob < ApplicationJob # In the event the job is started before the file is fully uploaded, we'll retry retry_on Exceptions::FileNotUploadedError, wait: 10.seconds + retry_on Exceptions::PDFExtractionError, wait: 10.seconds + retry_on Exceptions::PDFPageConversionError, wait: 10.seconds def perform(resource_id) - # Only convert if the passed resource is an image resource = Resource.find(resource_id) - return unless resource.image? - - # Only convert if there is content attached + + # Return early if no content is attached content = resource.content return unless content.attached? raise Exceptions::FileNotUploadedError unless resource.content_uploaded? + # Route to appropriate converter based on content type + if resource.image? + convert_image(resource) + elsif resource.pdf? + convert_pdf(resource) + end + end + + private + + # Convert a single image file to TIFF + # Maintains backwards compatibility with existing image conversion workflow + # @param resource [Resource] The resource containing the image to convert + def convert_image(resource) + content = resource.content + content.open do |file| begin - # Convert the image + # Convert the image to TIFF using existing service filepath = Images::Convert.to_tiff(file) filename = Images::Convert.filename content.filename.to_s, FILE_EXTENSION_TIFF - # Upload the converted content + # Upload the converted content to content_converted (single file attachment) resource.content_converted.attach( io: File.open(filepath), content_type: CONTENT_TYPE_TIFF, filename:, ) rescue MiniMagick::Error => e - # Content cannot be converted - Rails.logger.error e.message + # Content cannot be converted - log and continue (non-fatal) + Rails.logger.error "Error converting image for resource #{resource.id}: #{e.message}" Rails.logger.error e.backtrace.join("\n") + ensure + # Clean up temporary file + File.delete(filepath) if filepath && File.exist?(filepath) + end + end + end + + # Convert a multi-page PDF to an ordered set of TIFF files + # @param resource [Resource] The resource containing the PDF to convert + def convert_pdf(resource) + content = resource.content + temp_files = [] + + begin + content.open do |file| + # Extract page count from PDF + page_count = Images::ConvertPdf.page_count(file) + + # Process each page + page_count.times do |page_number| + begin + # Extract page as intermediate image + temp_image_path = Images::ConvertPdf.extract_page(file, page_number) + temp_files << temp_image_path + + # Convert extracted page to TIFF + tiff_path = Images::ConvertPdf.page_to_tiff(File.new(temp_image_path)) + temp_files << tiff_path + + # Generate filename for this page (e.g., "document_page_001.tif") + base_filename = File.basename(content.filename.to_s, '.*') + page_filename = "#{base_filename}_page_#{page_number + 1}.#{FILE_EXTENSION_TIFF}" + + # Attach TIFF to content_converted_pages in order + resource.content_converted_pages.attach( + io: File.open(tiff_path), + content_type: CONTENT_TYPE_TIFF, + filename: page_filename, + metadata: { original_page_number: page_number + 1 }, + ) + + # Clean up intermediate files as we go + Images::ConvertPdf.cleanup_temp_files([temp_image_path, tiff_path]) + temp_files -= [temp_image_path, tiff_path] + + rescue Exceptions::PDFExtractionError => e + Rails.logger.error "Failed to extract page #{page_number} from PDF for resource #{resource.id}: #{e.message}" + # Continue with next page rather than failing entire job + rescue Exceptions::PDFPageConversionError => e + Rails.logger.error "Failed to convert page #{page_number} to TIFF for resource #{resource.id}: #{e.message}" + # Continue with next page rather than failing entire job + end + end + + # Store page count on resource for tracking + resource.update(pages_count: page_count) + + # Regenerate manifest with converted pages + CreateManifestJob.perform_later(resource.id) + + Rails.logger.info "Successfully converted PDF resource #{resource.id} with #{page_count} pages" + end + rescue Exceptions::EmptyPDFError => e + Rails.logger.error "PDF resource #{resource.id} is empty: #{e.message}" + rescue Exceptions::PDFExtractionError => e + Rails.logger.error "Failed to extract pages from PDF resource #{resource.id}: #{e.message}" + ensure + # Ensure all temporary files are cleaned up + Images::ConvertPdf.cleanup_temp_files(temp_files) end end end diff --git a/app/models/concerns/attachable.rb b/app/models/concerns/attachable.rb index 71e40ab..c16dbdb 100644 --- a/app/models/concerns/attachable.rb +++ b/app/models/concerns/attachable.rb @@ -18,7 +18,12 @@ def delete_attachments attachment = self.send(name) return unless attachment.attached? - attachment.purge + # Handle both has_one_attached and has_many_attached + if attachment.is_a?(ActiveStorage::Attached::Many) + attachment.purge_all + else + attachment.purge + end end end @@ -31,14 +36,21 @@ def set_attachment_keys self.class.list_attachments&.each do |name| attachment = self.send(name) - - # Only set the storage key for a new record - next unless attachment.new_record? - - # If the "storage_key" attribute is set on the metadata, this blob was created as a direct upload - next if attachment.metadata && attachment.metadata[:storage_key].present? - - attachment.key = "#{self.storage_key}/#{ActiveStorage::Blob.generate_unique_secure_token}" + + # Handle both has_one_attached and has_many_attached + if attachment.is_a?(ActiveStorage::Attached::Many) + # For has_many_attached, iterate through each attachment + attachment.each do |attach| + next unless attach.new_record? + next if attach.metadata && attach.metadata[:storage_key].present? + attach.key = "#{self.storage_key}/#{ActiveStorage::Blob.generate_unique_secure_token}" + end + else + # For has_one_attached + next unless attachment.new_record? + next if attachment.metadata && attachment.metadata[:storage_key].present? + attachment.key = "#{self.storage_key}/#{ActiveStorage::Blob.generate_unique_secure_token}" + end end end end @@ -47,18 +59,20 @@ def set_attachment_keys # This method overrides model#has_one_attached in order to facilitate generating a _url method for easily # accessing the attachment URL in serializers. - def has_one_attached(name, dependent: :purge_later) - super - generate_url_method name + # Pass generate_urls: false when the model defines its own _*_url methods (e.g. page-indexed URLs). + def has_one_attached(name, dependent: :purge_later, generate_urls: true) + super(name, dependent: dependent) + generate_url_method name if generate_urls generate_remove_method name @attachments << name end # This method overrides model#has_many_attached in order to facilitate generating a _url method for easily # accessing the attachment URL in serializers. - def has_many_attached(name, dependent: :purge_later) - super - generate_url_method name + # Pass generate_urls: false when the model defines its own _*_url methods (e.g. page-indexed URLs). + def has_many_attached(name, dependent: :purge_later, generate_urls: true) + super(name, dependent: dependent) + generate_url_method name if generate_urls generate_remove_method name @attachments << name end @@ -163,7 +177,16 @@ def generate_url_method(name) end def attachment_preloads - @attachments.map{ |a| { "#{a}_attachment".to_sym => :blob } } + @attachments.map do |a| + # For has_many_attached, the association is plural: {name}_attachments + # For has_one_attached, the association is singular: {name}_attachment + # Check if the reflection exists to determine which pattern to use + if reflect_on_association("#{a}_attachments") + { "#{a}_attachments".to_sym => :blob } + else + { "#{a}_attachment".to_sym => :blob } + end + end end def list_attachments diff --git a/app/models/resource.rb b/app/models/resource.rb index c614e32..6e39233 100644 --- a/app/models/resource.rb +++ b/app/models/resource.rb @@ -21,6 +21,8 @@ class Resource < ApplicationRecord # ActiveStorage has_one_attached :content has_one_attached :content_converted + # Page URLs are page-indexed and defined below, so skip the generic (page-unaware) macro-generated ones. + has_many_attached :content_converted_pages, generate_urls: false # Delegates delegate :audio?, to: :content @@ -53,6 +55,11 @@ def self.without_attachment(name) end def content_base_url + # For multi-page PDFs, this should return the info for the whole PDF (including page count) + if content_converted_pages.attached? + return "#{ENV['IIIF_HOST_DOCKER'] || ENV['IIIF_HOST']}/iiif/3/#{CGI.escape(content.key)}" + end + return attachable_content_base_url unless content_converted.attached? "#{ENV['IIIF_HOST_DOCKER'] || ENV['IIIF_HOST']}/iiif/3/#{CGI.escape(content_converted.key)}" @@ -89,11 +96,102 @@ def content_thumbnail_url end def content_type + # For multi-page PDFs, return PTIF content type + return 'image/tiff' if content_converted_pages.attached? + return content.content_type unless content_converted.attached? content_converted.content_type end + # IIIF methods for multi-page PDFs + # These methods provide access to individual page URLs in multi-page conversions + + # Get the base IIIF URL for a specific page in a multi-page PDF + # @param page_number [Integer] 1-indexed page number + # @return [String, nil] The base URL for the page, or nil if not found + def content_converted_pages_base_url(page_number) + return nil unless content_converted_pages.attached? + page_number = page_number.to_i + return nil unless pages_count + return nil if page_number < 1 || page_number > pages_count + + page = content_converted_pages.to_a.find do |attachment| + attachment.blob.metadata['original_page_number'].to_i == page_number + end + return nil unless page + + "#{ENV['IIIF_HOST_DOCKER'] || ENV['IIIF_HOST']}/iiif/3/#{CGI.escape(page.key)}" + end + + # Get the full IIIF Image API URL for a specific page in a multi-page PDF + # @param page_number [Integer] 1-indexed page number + # @param region [String] IIIF region parameter (default: 'full') + # @param size [String] IIIF size parameter (default: 'max') + # @param rotation [String] IIIF rotation parameter (default: '0') + # @param quality [String] IIIF quality parameter (default: 'default') + # @param format [String] Image format (default: 'jpg') + # @return [String, nil] The full IIIF Image API URL, or nil if page not found + def content_converted_pages_image_api_url(page_number, region = 'full', size = 'max', rotation = '0', quality = 'default', format = 'jpg') + base_url = content_converted_pages_base_url(page_number) + return nil unless base_url + + "#{base_url}/#{region}/#{size}/#{rotation}/#{quality}.#{format}" + end + + # Get the IIIF info.json URL for a specific page in a multi-page PDF + # @param page_number [Integer] 1-indexed page number + # @return [String, nil] The IIIF info.json URL, or nil if page not found + def content_converted_pages_info_url(page_number) + base_url = content_converted_pages_base_url(page_number) + return nil unless base_url + + "#{base_url}/info.json" + end + + # Get the IIIF presentation URL for a specific page in a multi-page PDF + # @param page_number [Integer] 1-indexed page number + # @return [String, nil] The IIIF presentation URL, or nil if page not found + def content_converted_pages_iiif_url(page_number) + base_url = content_converted_pages_base_url(page_number) + return nil unless base_url + + "#{base_url}/full/max/0/default.jpg" + end + + # Get the IIIF thumbnail URL for a specific page in a multi-page PDF + # @param page_number [Integer] 1-indexed page number (default: 1 for first page) + # @return [String, nil] The IIIF thumbnail URL, or nil if page not found + def content_converted_pages_thumbnail_url(page_number = 1) + base_url = content_converted_pages_base_url(page_number) + return nil unless base_url + + "#{base_url}/square/^!250,250/0/default.jpg" + end + + # Get the IIIF preview URL for a specific page in a multi-page PDF + # @param page_number [Integer] 1-indexed page number (default: 1 for first page) + # @return [String, nil] The IIIF preview URL, or nil if page not found + def content_converted_pages_preview_url(page_number = 1) + base_url = content_converted_pages_base_url(page_number) + return nil unless base_url + + "#{base_url}/full/^!500,500/0/default.jpg" + end + + # Get all page keys for multi-page PDFs (used for manifest generation) + # @return [Array] Array of attachment keys in page order, empty array if single-file + def content_converted_pages_keys + return [] unless content_converted_pages.attached? + content_converted_pages.map(&:key) + end + + # Get the number of pages (only set for multi-page PDFs) + # @return [Integer, nil] Number of pages for PDFs, nil for images + def page_count + pages_count + end + def iiif? image? || video? || audio? || pdf? end @@ -102,6 +200,18 @@ def pdf? content.content_type == 'application/pdf' end + def converted_pages? + content_converted_pages.attached? + end + + def converted_single_file? + content_converted.attached? + end + + def iiif_conversion + converted_pages? ? :multi_page : :single_file + end + private def self.attachment_subquery(name) diff --git a/app/services/iiif/manifest.rb b/app/services/iiif/manifest.rb index 47c06d0..ef02399 100644 --- a/app/services/iiif/manifest.rb +++ b/app/services/iiif/manifest.rb @@ -36,7 +36,7 @@ def self.add_resource(resource, canvas_metadata: false) if resource.image? || resource.pdf? info = resource_info(resource) - page_count = info['page_count'] || 1 + page_count = resource.pdf? ? resource.page_count : (info['page_count'] || 1) height = info['height'] width = info['width'] else @@ -63,8 +63,10 @@ def self.create_annotation(resource, target, page_number, width, height) annotation['id'] = "#{base_url(resource)}/canvas/#{page_number}/page/1/annotation/1" annotation['target'] = target - if resource.image? || resource.pdf? + if resource.image? id = "#{base_url(resource)};#{page_number}/iiif" + elsif resource.pdf? + id = resource.content_converted_pages_iiif_url(page_number) else id = resource.content_url end @@ -92,7 +94,7 @@ def self.create_annotation(resource, target, page_number, width, height) if resource.image? || resource.pdf? annotation['body']['service'] = [{ - id: "#{base_url(resource)};#{page_number}", + id: resource.image? ? "#{base_url(resource)};#{page_number}" : resource.content_converted_pages_base_url(page_number), type: 'ImageService3', profile: 'level2' }] diff --git a/app/services/images/convert.rb b/app/services/images/convert.rb index d9ac2fd..2c016fc 100644 --- a/app/services/images/convert.rb +++ b/app/services/images/convert.rb @@ -18,6 +18,8 @@ def self.to_tiff(file) convert << 'jpeg' convert << '-alpha' convert << 'remove' + convert << '-alpha' + convert << 'off' convert << '-colorspace' convert << 'sRGB' convert << "ptif:#{output_path}" diff --git a/app/services/images/convert_pdf.rb b/app/services/images/convert_pdf.rb new file mode 100644 index 0000000..1414234 --- /dev/null +++ b/app/services/images/convert_pdf.rb @@ -0,0 +1,130 @@ +module Images + class ConvertPdf + # Extract the total number of pages from a PDF file + # @param file [File] The PDF file to analyze + # @return [Integer] The number of pages in the PDF + # @raise [Exceptions::PDFExtractionError] If PDF cannot be read or is corrupted + def self.page_count(file) + begin + # Use ImageMagick to identify PDF structure + # We need to count the actual pages available + identify = MiniMagick::Tool::Identify.new + identify << file.path + output = identify.call + + # Parse output to extract page count + # ImageMagick identify on a PDF returns one line per page, e.g. "file.pdf[3] PDF ..." + pages = output.scan(/\[(\d+)\]/).flatten.uniq.count + + if pages.zero? + # Fallback: try to use pdftoppm or count with strings + pages = count_pdf_pages_alternative(file) + end + + raise Exceptions::EmptyPDFError, "PDF has no extractable pages" if pages.zero? + + pages + rescue MiniMagick::Error => e + raise Exceptions::PDFExtractionError, "Failed to extract page count from PDF: #{e.message}" + end + end + + # Extract a single page from PDF as an intermediate image file + # @param file [File] The PDF file + # @param page_number [Integer] The page number (0-indexed) + # @param output_format [String] Output image format (default: 'png') + # @return [String] Path to the extracted image file + # @raise [Exceptions::PDFExtractionError] If page extraction fails + def self.extract_page(file, page_number, output_format = 'png') + begin + # Generate output filename with page number + base_filename = File.basename(file.path, '.*') + output_filename = "#{base_filename}_page_#{page_number + 1}.#{output_format}" + output_path = File.join(File.dirname(file.path), output_filename) + + # Use ImageMagick to extract the specific PDF page + convert = MiniMagick.convert + convert << '-density' + convert << '300' # High DPI for better quality + convert << "#{file.path}[#{page_number}]" # Specify page index (0-indexed) + convert << '-quality' + convert << '90' # Good quality for intermediate conversion + convert << output_path + convert.call + + raise Exceptions::PDFExtractionError, "Output file not created" unless File.exist?(output_path) + + output_path + rescue MiniMagick::Error => e + raise Exceptions::PDFExtractionError, "Failed to extract page #{page_number} from PDF: #{e.message}" + end + end + + # Convert an extracted PDF page (as image) to TIFF format + # @param image_file [File] The intermediate image file (result of extract_page) + # @return [String] Path to the converted TIFF file + # @raise [Exceptions::PDFPageConversionError] If conversion fails + def self.page_to_tiff(image_file) + begin + output_file = "#{File.basename(image_file.path, '.*')}.tif" + output_path = File.join(File.dirname(image_file.path), output_file) + + convert = MiniMagick.convert + convert << image_file.path + convert << '-density' + convert << '300' + convert << '-define' + convert << 'tiff:tile-geometry=1024x1024' + convert << '-define' + convert << 'ptif:pyramid=1024x8' + convert << '-depth' + convert << '8' + convert << '-compress' + convert << 'jpeg' + convert << '-strip' + convert << '-alpha' + convert << 'remove' + convert << '-alpha' + convert << 'off' + convert << '-colorspace' + convert << 'sRGB' + convert << "ptif:#{output_path}" + convert.call + + output_path + rescue MiniMagick::Error => e + raise Exceptions::PDFPageConversionError, "Failed to convert page to TIFF: #{e.message}" + end + end + + # Clean up temporary intermediate image files + # @param file_paths [Array] Paths to temporary files to delete + def self.cleanup_temp_files(file_paths) + file_paths.each do |filepath| + File.delete(filepath) if File.exist?(filepath) + rescue Errno::ENOENT + # File already deleted, no action needed + rescue StandardError => e + Rails.logger.warn("Failed to cleanup temp file #{filepath}: #{e.message}") + end + end + + private + + # Alternative method to count PDF pages using string-based parsing + # This is a fallback if the primary method fails + # @param file [File] The PDF file + # @return [Integer] The number of pages + def self.count_pdf_pages_alternative(file) + # Read PDF file and count /Type /Page objects as a rough estimate + # This is a simple heuristic and may not work for all PDF types + pdf_content = File.read(file.path, encoding: 'ISO-8859-1') + + # Count /Type /Page occurrences, excluding /Type /Pages tree/container nodes + count = pdf_content.scan(%r{/Type\s*/Page(?!s)}).count + count = 1 if count.zero? # At minimum, a PDF has 1 page + + count + end + end +end diff --git a/db/migrate/20260817230654_add_pdf_support_to_resources.rb b/db/migrate/20260817230654_add_pdf_support_to_resources.rb new file mode 100644 index 0000000..42dc50f --- /dev/null +++ b/db/migrate/20260817230654_add_pdf_support_to_resources.rb @@ -0,0 +1,9 @@ +class AddPdfSupportToResources < ActiveRecord::Migration[8.0] + def change + # Add pages_count column to track PDF page count + add_column :resources, :pages_count, :integer + + # Add index for querying resources with multi-page conversions + add_index :resources, :pages_count + end +end diff --git a/lib/exceptions.rb b/lib/exceptions.rb index 3953edf..66658c9 100644 --- a/lib/exceptions.rb +++ b/lib/exceptions.rb @@ -1,3 +1,6 @@ module Exceptions class FileNotUploadedError < StandardError; end + class PDFExtractionError < StandardError; end + class PDFPageConversionError < StandardError; end + class EmptyPDFError < StandardError; end end \ No newline at end of file