Skip to content

Commit 86f8983

Browse files
committed
previews for epubs and pdfs
1 parent a6bbb1e commit 86f8983

File tree

9 files changed

+150
-4
lines changed

9 files changed

+150
-4
lines changed

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ WORKDIR /rails
1616

1717
# Install base packages
1818
RUN apt-get update -qq && \
19-
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
19+
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 poppler-utils && \
2020
rm -rf /var/lib/apt/lists /var/cache/apt/archives
2121

2222
# Set production environment

Dockerfile.dev

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
FROM ruby:3.4
2+
3+
# Install system dependencies including Poppler
4+
RUN apt-get update -qq && \
5+
apt-get install -y poppler-utils libvips-dev && \
6+
rm -rf /var/lib/apt/lists/*
7+
8+
WORKDIR /app

Gemfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,6 @@ gem "aws-sdk-s3", require: false
114114

115115
# read zip files
116116
gem "rubyzip"
117+
118+
# image processing for previews
119+
gem "image_processing"

Gemfile.lock

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,9 @@ GEM
171171
http-2 (>= 1.0.0)
172172
i18n (1.14.7)
173173
concurrent-ruby (~> 1.0)
174+
image_processing (1.14.0)
175+
mini_magick (>= 4.9.5, < 6)
176+
ruby-vips (>= 2.0.17, < 3)
174177
image_size (3.4.0)
175178
importmap-rails (2.2.0)
176179
actionpack (>= 6.0.0)
@@ -218,6 +221,8 @@ GEM
218221
net-smtp
219222
marcel (1.0.4)
220223
matrix (0.4.2)
224+
mini_magick (5.3.0)
225+
logger
221226
mini_mime (1.1.5)
222227
minitest (5.25.5)
223228
mission_control-jobs (1.1.0)
@@ -380,6 +385,9 @@ GEM
380385
rubocop-performance (>= 1.24)
381386
rubocop-rails (>= 2.30)
382387
ruby-progressbar (1.13.0)
388+
ruby-vips (2.2.4)
389+
ffi (~> 1.12)
390+
logger
383391
rubyzip (2.4.1)
384392
safety_net_attestation (0.4.0)
385393
jwt (~> 2.0)
@@ -496,6 +504,7 @@ DEPENDENCIES
496504
feedjira
497505
hotwire-spark
498506
httpx
507+
image_processing
499508
image_size (~> 3.4)
500509
importmap-rails
501510
invisible_captcha
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
require "zip"
2+
3+
class ActiveStorage::Previewer::EpubPreviewer < ActiveStorage::Previewer
4+
def self.accept?(blob)
5+
blob.content_type == "application/epub+zip"
6+
end
7+
8+
def preview(**options)
9+
download_blob_to_tempfile do |input|
10+
cover_data = extract_cover(input)
11+
if cover_data
12+
# Detect the actual image format
13+
extension = detect_extension(cover_data)
14+
content_type = extension == ".png" ? "image/png" : "image/jpeg"
15+
16+
Tempfile.create([ "cover", extension ]) do |output|
17+
output.binmode
18+
output.write(cover_data)
19+
output.rewind
20+
yield io: output, filename: "#{blob.filename.base}#{extension}", content_type: content_type
21+
end
22+
end
23+
end
24+
end
25+
26+
private
27+
28+
def extract_cover(file)
29+
Zip::File.open(file.path) do |zip|
30+
# Try the OPF method first
31+
cover_data = extract_from_opf(zip)
32+
return cover_data if cover_data
33+
34+
# Fallback: look for common cover file names
35+
cover_data = extract_from_common_names(zip)
36+
return cover_data if cover_data
37+
38+
nil
39+
end
40+
rescue
41+
nil
42+
end
43+
44+
def extract_from_opf(zip)
45+
opf_path = find_opf_path(zip)
46+
return unless opf_path
47+
48+
opf = Nokogiri::XML(zip.read(opf_path))
49+
cover_href = find_cover_href(opf)
50+
return unless cover_href
51+
52+
cover_path = File.join(File.dirname(opf_path), cover_href)
53+
zip.read(cover_path)
54+
rescue
55+
nil
56+
end
57+
58+
def extract_from_common_names(zip)
59+
# Common cover file patterns
60+
patterns = [
61+
/cover\.(jpe?g|png|gif)$/i,
62+
/front\.(jpe?g|png|gif)$/i
63+
]
64+
65+
# Look through all zip entries for cover images
66+
zip.entries.each do |entry|
67+
filename = File.basename(entry.name)
68+
if patterns.any? { |pattern| filename.match?(pattern) }
69+
return zip.read(entry.name)
70+
end
71+
end
72+
73+
nil
74+
end
75+
76+
def find_opf_path(zip)
77+
container = Nokogiri::XML(zip.read("META-INF/container.xml"))
78+
container.at("rootfile")["full-path"]
79+
rescue
80+
nil
81+
end
82+
83+
def find_cover_href(opf)
84+
# EPUB 3: Use CSS selectors to avoid namespace issues
85+
cover = opf.css("item[properties='cover-image']").first
86+
return cover["href"] if cover
87+
88+
# EPUB 3: properties containing "cover-image" (for cases with multiple properties)
89+
cover = opf.css("item[properties*='cover-image']").first
90+
return cover["href"] if cover
91+
92+
# EPUB 2: meta name="cover"
93+
meta = opf.css("meta[name='cover']").first
94+
if meta
95+
cover_id = meta["content"]
96+
cover = opf.css("item[id='#{cover_id}']").first
97+
return cover["href"] if cover
98+
end
99+
100+
# Additional fallback: look for items with "cover" in href
101+
cover = opf.css("item[href*='cover']").first
102+
return cover["href"] if cover
103+
104+
nil
105+
end
106+
107+
def detect_extension(data)
108+
case data[0..3]
109+
when "\x89PNG"
110+
".png"
111+
when "\xFF\xD8\xFF"
112+
".jpg"
113+
when "GIF8"
114+
".gif"
115+
else
116+
".jpg"
117+
end
118+
end
119+
end

app/views/documents/_preview.html.erb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
<p class="document-preview-description"><%= preview.description %></p>
1616
</div>
1717
<% if preview.thumbnail_url %>
18-
<img class="document-preview-thumbnail" src="<%= preview.thumbnail_url %>" onerror="this.dataset.failed=true"/>
18+
<img class="document-preview-thumbnail" src="<%= preview.thumbnail_url %>" onerror="this.dataset.failed=true"/>
19+
<% elsif preview.resource? && preview.resource.file.previewable? %>
20+
<%= image_tag preview.resource.file.preview(resize_to_limit: [240, 240]), class: "document-preview-thumbnail" %>
1921
<% end %>
2022
</div>
2123
<% end %>

app/views/documents/toolbar.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<turbo-frame id="document_toolbar">
22
<%= form_with model: @document, class: "toolbar", data: {controller: "toolbar"} do |form| %>
33
<%= form.hidden_field :id, value: params[:id] %>
4-
<% Document.statuses.keys.each do |status| %>
4+
<% ["inbox", "feed", "later", "archive"].each do |status| %>
55
<%= form.button type: :submit,
66
name: "document[status]",
77
value: status,
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Rails.application.config.to_prepare do
2+
Rails.application.config.active_storage.previewers += [ ActiveStorage::Previewer::EpubPreviewer ]
3+
end

docker-compose.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ services:
2323
networks:
2424
- starch-network
2525
web:
26-
image: ruby:3.4
26+
build:
27+
context: .
28+
dockerfile: Dockerfile.dev
2729
working_dir: /app
2830
volumes:
2931
- .:/app

0 commit comments

Comments
 (0)