summaryrefslogtreecommitdiff
path: root/app/models
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-30 04:40:08 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-30 04:40:08 +0200
commit6c62d0c538e6b8baa416a7ed901a74a66de44348 (patch)
tree8898d14562a8c8b2e7e3a10a9a43d0018372a6f9 /app/models
parent02250d025043f9953923a7be09605830d554db10 (diff)
Generate a 1200x630 social card variant for every asset
Diffstat (limited to 'app/models')
-rw-r--r--app/models/concerns/file_attachment.rb166
1 files changed, 161 insertions, 5 deletions
diff --git a/app/models/concerns/file_attachment.rb b/app/models/concerns/file_attachment.rb
index e577c7fa..5e0803fd 100644
--- a/app/models/concerns/file_attachment.rb
+++ b/app/models/concerns/file_attachment.rb
@@ -1,3 +1,5 @@
1require "open3"
2
1# FileAttachment — minimal drop-in replacement for Paperclip's has_attached_file. 3# FileAttachment — minimal drop-in replacement for Paperclip's has_attached_file.
2# 4#
3# Provides the same interface used throughout this codebase: 5# Provides the same interface used throughout this codebase:
@@ -16,8 +18,6 @@
16# The filename is fixed on first upload and preserved on replacement, 18# The filename is fixed on first upload and preserved on replacement,
17# keeping all public URLs stable. 19# keeping all public URLs stable.
18# 20#
19# Future: if more sophisticated asset management is needed (versioning,
20# S3, on-demand resizing), replace this module and keep the interface.
21 21
22module FileAttachment 22module FileAttachment
23 extend ActiveSupport::Concern 23 extend ActiveSupport::Concern
@@ -34,6 +34,46 @@ module FileAttachment
34 RASTERIZED_CONTENT_TYPES = %w[application/pdf].freeze 34 RASTERIZED_CONTENT_TYPES = %w[application/pdf].freeze
35 DISPLAYABLE_AS_IMAGE = IMAGE_CONTENT_TYPES + VECTOR_CONTENT_TYPES 35 DISPLAYABLE_AS_IMAGE = IMAGE_CONTENT_TYPES + VECTOR_CONTENT_TYPES
36 36
37 # ---- Social card (:og variant) -------------------------------------
38 #
39 # 1200x630 is the de facto standard: Facebook codified it and Discord,
40 # Slack, WhatsApp, LinkedIn, Bluesky, Threads and Mastodon all render it
41 # as a full-width card.
42 OG_WIDTH = 1200
43 OG_HEIGHT = 630
44
45 # public/images/social_card.png carries ground, knot and wordmark as
46 # pixels. Code only fits a source into the slot and draws the paper edge
47 # and drop shadow, whose outline follows each source's shape and so
48 # cannot be baked into the template.
49 OG_TEMPLATE = Rails.root.join("public", "images", "social_card.png")
50
51 # The paper rectangle in template pixels, origin top-left, measured
52 # WITHOUT the shadow. If the template's layout changes, this changes with
53 # it in the same commit -- there is no way for code to detect a mismatch.
54 OG_SLOT = { :x => 75, :y => 70, :width => 346, :height => 490 }.freeze
55
56 # Hairline around the page, so a white document separates from the
57 # ground. #CACACA is ccc.css's --paper-edge at full opacity.
58 OG_PAPER_EDGE = "#CACACA"
59 OG_PAPER_BORDER = 3
60
61 # ImageMagick -shadow: opacity percent x sigma, then offset. Sigma 10
62 # spreads about 20px, plus the 6px drop, so 40px of margin around the
63 # slot holds it comfortably.
64 OG_SHADOW = "50x10+0+6"
65 OG_SHADOW_MARGIN = 40
66
67 OG_GROUND = "#4D4D4D"
68
69 # Photographs at least this wide relative to their height fill the frame
70 # edge to edge; losing 12% of a 3:2 photo's height is invisible. Anything
71 # squarer or taller goes on the template, where the ground does the work
72 # that cropping would otherwise do by discarding content. Documents never
73 # crop regardless of shape -- the whole page is the message.
74 OG_CROP_RATIO = 1.5
75
76
37 included do 77 included do
38 attr_reader :upload 78 attr_reader :upload
39 79
@@ -62,8 +102,18 @@ module FileAttachment
62 end 102 end
63 103
64 def variant_filename(style) 104 def variant_filename(style)
65 return upload_file_name if style == :original || !RASTERIZED_CONTENT_TYPES.include?(upload_content_type) 105 return upload_file_name if style == :original
66 File.basename(upload_file_name, ".*") + ".png" 106
107 base = File.basename(upload_file_name, ".*")
108
109 # Rasterised sources are PNG in every style
110 return "#{base}.png" if RASTERIZED_CONTENT_TYPES.include?(upload_content_type)
111
112 if style == :og
113 return VECTOR_CONTENT_TYPES.include?(upload_content_type) ? "#{base}.png" : "#{base}.jpg"
114 end
115
116 upload_file_name
67 end 117 end
68 118
69 private 119 private
@@ -114,17 +164,123 @@ module FileAttachment
114 end 164 end
115 end 165 end
116 166
167 # 1200x630 social card. Landscape photographs fill the frame; documents,
168 # vector art and portrait images sit on the template with a paper edge and
169 # a drop shadow. Always opaque: a transparent PNG renders unreadable in
170 # dark-mode clients, which bleed their own background through.
171 def generate_og_variant(original_path)
172 dest_path = file_path(:og)
173 FileUtils.mkdir_p(File.dirname(dest_path))
174
175 if og_full_bleed?(original_path)
176 system("magick", "#{original_path}[0]",
177 "-resize", "#{OG_WIDTH}x#{OG_HEIGHT}^",
178 "-gravity", "center", "-extent", "#{OG_WIDTH}x#{OG_HEIGHT}",
179 "-background", OG_GROUND, "-alpha", "remove", "-alpha", "off",
180 *og_output_args, dest_path)
181 else
182 system(*og_template_command(dest_path))
183 end
184 end
185
117 def generate_all_variants(original_path) 186 def generate_all_variants(original_path)
118 if IMAGE_CONTENT_TYPES.include?(upload_content_type) 187 if IMAGE_CONTENT_TYPES.include?(upload_content_type)
119 generate_variants(original_path) 188 generate_variants(original_path)
120 elsif VECTOR_CONTENT_TYPES.include?(upload_content_type) 189 elsif VECTOR_CONTENT_TYPES.include?(upload_content_type)
121 generate_svg_variants(original_path) 190 generate_svg_variants(original_path)
122 elsif RASTERIZED_CONTENT_TYPES.include?(upload_content_type) 191 elsif RASTERIZED_CONTENT_TYPES.include?(upload_content_type)
123 generate_variants("#{original_path}[0]", 192 unless source_dimensions(original_path)
193 Rails.logger.warn("Asset##{id}: #{upload_file_name} has no rasterisable first page, skipping variants")
194 return false
195 end
196
197 generate_variants("#{original_path}[0]",
124 ["-background", "white", "-alpha", "remove", "-alpha", "off"]) 198 ["-background", "white", "-alpha", "remove", "-alpha", "off"])
199 else
200 # Not displayable as an image at all: no variants, no card.
201 return false
202 end
203
204 generate_og_variant(original_path)
205 true
206 end
207
208 def source_dimensions(path)
209 out, status = Open3.capture2("magick", "identify", "-format", "%w %h", "#{path}[0]")
210 return nil unless status.success?
211
212 width, height = out.split.map(&:to_i)
213 (width.positive? && height.positive?) ? [width, height] : nil
214 rescue Errno::ENOENT
215 nil
216 end
217
218 # True when the source should fill the whole 1200x630 frame rather than
219 # sit on the template
220 def og_full_bleed?(path)
221 return false unless IMAGE_CONTENT_TYPES.include?(upload_content_type)
222
223 dimensions = source_dimensions(path)
224 return false unless dimensions
225
226 (dimensions[0].to_f / dimensions[1]) >= OG_CROP_RATIO
227 end
228
229 # Fits the source into OG_SLOT, borders it, drops a shadow behind it, then
230 # pastes the result onto the template. OG_SLOT is the paper rectangle
231 # without the shadow, so the layer is padded by OG_SHADOW_MARGIN on every
232 # side and pasted that much up and to the left -- which keeps the paper
233 # itself exactly where it was measured.
234 def og_template_command(dest_path)
235 rasterized = RASTERIZED_CONTENT_TYPES.include?(upload_content_type)
236 original = file_path(:original)
237
238 # -density must precede the input filename to affect PDF rasterisation,
239 # which is why this cannot be a STYLES entry: that loop puts all
240 # arguments after the input. 150dpi gives A4 about 1240x1754, ample for
241 # a 490px-tall thumbnail.
242 density = rasterized ? ["-density", "150"] : []
243 source = rasterized ? "#{original}[0]" : original
244 flatten = rasterized ? ["-background", "white", "-alpha", "remove", "-alpha", "off"] : []
245
246 paper = "#{OG_SLOT[:width]}x#{OG_SLOT[:height]}"
247 envelope = "#{OG_SLOT[:width] + 2 * OG_SHADOW_MARGIN}x#{OG_SLOT[:height] + 2 * OG_SHADOW_MARGIN}"
248 paste_x = OG_SLOT[:x] - OG_SHADOW_MARGIN
249 paste_y = OG_SLOT[:y] - OG_SHADOW_MARGIN
250
251 ["magick", OG_TEMPLATE.to_s,
252 "(", *density, source, *flatten,
253 # > means a source smaller than the slot stays at native size
254 # rather than being upscaled into softness.
255 "-resize", "#{paper}>",
256 "-bordercolor", OG_PAPER_EDGE, "-border", OG_PAPER_BORDER.to_s,
257 "(", "+clone", "-background", "black", "-shadow", OG_SHADOW, ")",
258 "+swap", "-background", "none", "-layers", "merge", "+repage",
259 "-background", "none", "-gravity", "center", "-extent", envelope,
260 ")",
261 "-gravity", "northwest", "-geometry", "+#{paste_x}+#{paste_y}",
262 "-composite",
263 "-background", OG_GROUND, "-alpha", "remove", "-alpha", "off",
264 *og_output_args, dest_path]
265 end
266
267 def og_output_args
268 if RASTERIZED_CONTENT_TYPES.include?(upload_content_type) ||
269 VECTOR_CONTENT_TYPES.include?(upload_content_type)
270 # PNG without palette reduction: the drop shadow is a smooth alpha
271 # ramp, and quantising it dithers into visible speckle along the
272 # edge. JPEG is no better here, since it rings around the small
273 # black-on-white text of a document page.
274 ["-define", "png:compression-level=9", "-strip"]
275 else
276 # -strip also removes EXIF, so a headline photograph's camera model
277 # and GPS coordinates do not travel to every platform that fetches
278 # the card.
279 ["-quality", "82", "-sampling-factor", "4:4:4", "-strip"]
125 end 280 end
126 end 281 end
127 282
283
128 def delete_upload_files 284 def delete_upload_files
129 dir = upload_root.join(id.to_s) 285 dir = upload_root.join(id.to_s)
130 FileUtils.rm_rf(dir) if Dir.exist?(dir) 286 FileUtils.rm_rf(dir) if Dir.exist?(dir)