-
Notifications
You must be signed in to change notification settings - Fork 24
WAIT: Embed multi-page handout PDFs inline with a page-count badge #1893
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π€ From Claude: |
||
| 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 | ||
| 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> | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| <% end %> | ||
| <%= link_to resource_download_path(resource), download: true, class: "btn btn-utility" do %> | ||
|
|
@@ -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 %> | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π€ From Claude: Inline embed is gated on |
||
| <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 %> | ||
| 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 |
| 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 |
| 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 |
There was a problem hiding this comment.
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.