Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ jobs:
- name: Checkout code
uses: actions/checkout@v6

# poppler-utils provides pdftoppm/pdfinfo, used by the PDF previewer and the
# PDF page-count analyzer (mirrors the production runtime dependency).
- name: Install poppler-utils
run: sudo apt-get update && sudo apt-get install -y poppler-utils

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: poppler is already a prod runtime dependency (the PDF previewer shells out to pdftoppm); installing it here lets the new PDF specs actually run instead of self-skipping.


- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -420,8 +420,9 @@ RuboCop linting on PRs and pushes to main.

## Rake Tasks

Located in `lib/tasks/` (4 files):
Located in `lib/tasks/` (5 files):
- `dev.rake` β€” Development database seeding from XML/CSV
- `rhino_migrator.rake` β€” Rich text editor migration
- `attachment_report.rake` β€” Attachment reporting
- `migrate_internal_id_to_filemaker_code.rake` β€” FileMaker code migration
- `pdfs.rake` β€” Backfill page-count metadata for existing PDF attachments (`pdfs:backfill_page_counts`)
41 changes: 41 additions & 0 deletions app/analyzers/pdf_page_count_analyzer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
require "open3"

# Records the page count of PDF attachments in blob metadata (metadata["pages"]),
# so views can show a "N pages" indicator and decide whether to offer a scrollable
# multi-page preview without shelling out per request. Uses poppler's `pdfinfo`
# (already a runtime dependency via HighResPopplerPdfPreviewer); silently skipped
# when poppler isn't installed.
class PdfPageCountAnalyzer < ActiveStorage::Analyzer
class << self
def accept?(blob)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: accept? returns false when pdfinfo is absent, so the analyzer is a no-op (no pages metadata) rather than raising β€” the view then degrades to the first-page preview. Mirrors how HighResPopplerPdfPreviewer guards on pdftoppm.

blob.content_type == "application/pdf" && pdfinfo_exists?
end

def pdfinfo_path
ActiveStorage.paths[:pdfinfo] || "pdfinfo"
end

def pdfinfo_exists?
return @pdfinfo_exists if defined?(@pdfinfo_exists)

@pdfinfo_exists = system(pdfinfo_path, "-v", out: File::NULL, err: File::NULL)
end
end

def metadata
download_blob_to_tempfile do |file|
pages = page_count(file)
pages ? { pages: pages } : {}
end
end

private

def page_count(file)
output, status = Open3.capture2(self.class.pdfinfo_path, file.path)
return unless status.success?

match = output.match(/^Pages:\s+(\d+)/)
match && match[1].to_i
end
end
38 changes: 29 additions & 9 deletions app/views/events/callouts/_resource_body.html.erb
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
<%# Renders a resource inside a callout page: a top-right download button (when a
downloadable file is attached) plus the resource display (PDF first-page preview
etc., via the same pipeline as resources/show). `resource` is a decorated
Resource. Shared by the registrant resource viewer and admin callouts that link
a resource. %>
<% if resource.downloadable_asset&.file&.attached? %>
downloadable file is attached) with a "N pages" badge for multi-page PDFs. A
PDF with a known page count > 1 embeds inline as a scrollable viewer, falling
back to the first-page preview where inline PDFs aren't supported (e.g. some
mobile browsers). When the page count isn't known yet (e.g. before the
backfill runs) a resource we still recognize as multi-page keeps the
"download for all pages" prompt. Single-page PDFs and everything else render
via the shared asset pipeline. `resource` is a decorated Resource. Shared by
the registrant resource viewer and admin callouts that link a resource. %>
<% file = resource.downloadable_asset&.file %>
<% pages = file&.attached? && file.content_type == "application/pdf" ? file.metadata["pages"].to_i : 0 %>
<% if file&.attached? %>
<div class="flex flex-wrap items-center justify-end gap-x-3 gap-y-1 mb-4">
<% unless resource.single_page_pdf? %>
<% if pages > 1 %>
<span class="inline-flex items-center gap-1 text-sm text-gray-600">
<i class="fa-regular fa-copy"></i> <%= pages %> pages
</span>
<% elsif !resource.single_page_pdf? %>
<span class="text-xs text-gray-500">The preview shows the first page only β€” download to get all pages.</span>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Rebase conflict with #1892 resolved here: the badge + inline embed key off the real metadata["pages"] (the introspection #1892's single_page_pdf? comment anticipated), while single_page_pdf? is kept as the pre-backfill fallback β€” before pdfs:backfill_page_counts runs, a recognized multi-page PDF still shows the "download for all pages" prompt instead of silently looking single-page.

<% end %>
<%= link_to resource_download_path(resource), download: true, class: "btn btn-utility" do %>
Expand All @@ -16,6 +26,16 @@

<p class="text-xs text-gray-400 mb-3">The preview below may take a moment to load.</p>

<%= render "assets/display_assets",
resource: resource, file: resource.display_image,
variant: :hero, link: true %>
<% if pages > 1 %>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Inline embed is gated on pages > 1: single-page PDFs keep the crisp first-page image, and if page count is unknown (poppler missing or not yet analyzed) we fall back to the image rather than embedding. The display_assets render inside <object> is the fallback for browsers that won't render inline PDFs (notably mobile Safari/Chrome).

<object data="<%= rails_blob_path(file, disposition: :inline) %>" type="application/pdf"
class="w-full h-[80vh] rounded-lg border border-gray-200"
aria-label="<%= resource.title %> (PDF preview)">
<%= render "assets/display_assets",
resource: resource, file: resource.display_image,
variant: :hero, link: true %>
</object>
<% else %>
<%= render "assets/display_assets",
resource: resource, file: resource.display_image,
variant: :hero, link: true %>
<% end %>
1 change: 1 addition & 0 deletions config/initializers/active_storage_previewers.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
Rails.application.config.after_initialize do
Rails.application.config.active_storage.previewers.unshift HighResPopplerPdfPreviewer
Rails.application.config.active_storage.analyzers.unshift PdfPageCountAnalyzer
end
17 changes: 17 additions & 0 deletions lib/tasks/pdfs.rake
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace :pdfs do
desc "Backfill page-count metadata (metadata[\"pages\"]) for existing PDF attachments"
task backfill_page_counts: :environment do
scope = ActiveStorage::Blob.where(content_type: "application/pdf")
total = scope.count
puts "Analyzing #{total} PDF blob(s)…"

scope.find_each.with_index(1) do |blob, i|
blob.analyze
puts " [#{i}/#{total}] blob ##{blob.id} (#{blob.filename}) β†’ #{blob.reload.metadata["pages"] || "?"} pages"
rescue => e
warn " [#{i}/#{total}] blob ##{blob.id} failed: #{e.message}"
end

puts "Done."
end
end
34 changes: 34 additions & 0 deletions spec/analyzers/pdf_page_count_analyzer_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
require "rails_helper"

RSpec.describe PdfPageCountAnalyzer do
before { skip "requires poppler's pdfinfo" unless described_class.pdfinfo_exists? }

let(:multipage) do
ActiveStorage::Blob.create_and_upload!(
io: File.open(Rails.root.join("spec/fixtures/files/sample_multipage.pdf")),
filename: "sample_multipage.pdf", content_type: "application/pdf"
)
end
let(:image) do
ActiveStorage::Blob.create_and_upload!(
io: File.open(Rails.root.join("spec/fixtures/files/sample.png")),
filename: "sample.png", content_type: "image/png"
)
end

describe ".accept?" do
it "accepts PDFs" do
expect(described_class.accept?(multipage)).to be(true)
end

it "rejects non-PDFs" do
expect(described_class.accept?(image)).to be(false)
end
end

describe "#metadata" do
it "records the page count" do
expect(described_class.new(multipage).metadata).to include(pages: 2)
end
end
end
10 changes: 10 additions & 0 deletions spec/factories/downloadable_assets.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@
)
end

trait :multipage do
after(:build) do |asset|
asset.file.attach(
io: File.open(Rails.root.join("spec/fixtures/files/sample_multipage.pdf")),
filename: "sample_multipage.pdf",
content_type: "application/pdf"
)
end
end

trait :with_image do
after(:build) do |asset|
asset.file.attach(
Expand Down
Binary file added spec/fixtures/files/sample_multipage.pdf
Binary file not shown.
36 changes: 36 additions & 0 deletions spec/requests/events/registration_ticket_callouts_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,42 @@
end
end

context "when linked to a multi-page PDF resource" do
before { skip "requires poppler's pdfinfo" unless PdfPageCountAnalyzer.pdfinfo_exists? }

let(:resource) { create(:resource) }
let(:callout) { create(:registration_ticket_callout, event:, resource:, description: "") }

before do
asset = create(:downloadable_asset, :multipage, owner: resource)
asset.file.analyze
end

it "embeds a scrollable inline PDF and shows a page-count badge" do
get event_registration_ticket_callout_path(event, callout)

expect(response).to have_http_status(:ok)
expect(response.body).to include("2 pages")
expect(response.body).to include(rails_blob_path(resource.downloadable_asset.file, disposition: :inline))
expect(response.body).to include("type=\"application/pdf\"")
end
end

context "when linked to a single-page PDF resource" do
let(:resource) { create(:resource) }
let(:callout) { create(:registration_ticket_callout, event:, resource:, description: "") }

before { create(:downloadable_asset, owner: resource) }

it "does not show a page-count badge or an inline embed" do
get event_registration_ticket_callout_path(event, callout)

expect(response).to have_http_status(:ok)
expect(response.body).not_to include("fa-regular fa-copy")
expect(response.body).not_to include("type=\"application/pdf\"")
end
end

context "when linked to a resource without a downloadable file" do
let(:resource) { create(:resource) }
let(:callout) do
Expand Down