From 6c62d0c538e6b8baa416a7ed901a74a66de44348 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 30 Jul 2026 04:40:08 +0200 Subject: Generate a 1200x630 social card variant for every asset --- app/models/concerns/file_attachment.rb | 166 ++++++++++++++++++++++++++++++++- 1 file changed, 161 insertions(+), 5 deletions(-) (limited to 'app/models/concerns') 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 @@ +require "open3" + # FileAttachment — minimal drop-in replacement for Paperclip's has_attached_file. # # Provides the same interface used throughout this codebase: @@ -16,8 +18,6 @@ # The filename is fixed on first upload and preserved on replacement, # keeping all public URLs stable. # -# Future: if more sophisticated asset management is needed (versioning, -# S3, on-demand resizing), replace this module and keep the interface. module FileAttachment extend ActiveSupport::Concern @@ -34,6 +34,46 @@ module FileAttachment RASTERIZED_CONTENT_TYPES = %w[application/pdf].freeze DISPLAYABLE_AS_IMAGE = IMAGE_CONTENT_TYPES + VECTOR_CONTENT_TYPES + # ---- Social card (:og variant) ------------------------------------- + # + # 1200x630 is the de facto standard: Facebook codified it and Discord, + # Slack, WhatsApp, LinkedIn, Bluesky, Threads and Mastodon all render it + # as a full-width card. + OG_WIDTH = 1200 + OG_HEIGHT = 630 + + # public/images/social_card.png carries ground, knot and wordmark as + # pixels. Code only fits a source into the slot and draws the paper edge + # and drop shadow, whose outline follows each source's shape and so + # cannot be baked into the template. + OG_TEMPLATE = Rails.root.join("public", "images", "social_card.png") + + # The paper rectangle in template pixels, origin top-left, measured + # WITHOUT the shadow. If the template's layout changes, this changes with + # it in the same commit -- there is no way for code to detect a mismatch. + OG_SLOT = { :x => 75, :y => 70, :width => 346, :height => 490 }.freeze + + # Hairline around the page, so a white document separates from the + # ground. #CACACA is ccc.css's --paper-edge at full opacity. + OG_PAPER_EDGE = "#CACACA" + OG_PAPER_BORDER = 3 + + # ImageMagick -shadow: opacity percent x sigma, then offset. Sigma 10 + # spreads about 20px, plus the 6px drop, so 40px of margin around the + # slot holds it comfortably. + OG_SHADOW = "50x10+0+6" + OG_SHADOW_MARGIN = 40 + + OG_GROUND = "#4D4D4D" + + # Photographs at least this wide relative to their height fill the frame + # edge to edge; losing 12% of a 3:2 photo's height is invisible. Anything + # squarer or taller goes on the template, where the ground does the work + # that cropping would otherwise do by discarding content. Documents never + # crop regardless of shape -- the whole page is the message. + OG_CROP_RATIO = 1.5 + + included do attr_reader :upload @@ -62,8 +102,18 @@ module FileAttachment end def variant_filename(style) - return upload_file_name if style == :original || !RASTERIZED_CONTENT_TYPES.include?(upload_content_type) - File.basename(upload_file_name, ".*") + ".png" + return upload_file_name if style == :original + + base = File.basename(upload_file_name, ".*") + + # Rasterised sources are PNG in every style + return "#{base}.png" if RASTERIZED_CONTENT_TYPES.include?(upload_content_type) + + if style == :og + return VECTOR_CONTENT_TYPES.include?(upload_content_type) ? "#{base}.png" : "#{base}.jpg" + end + + upload_file_name end private @@ -114,17 +164,123 @@ module FileAttachment end end + # 1200x630 social card. Landscape photographs fill the frame; documents, + # vector art and portrait images sit on the template with a paper edge and + # a drop shadow. Always opaque: a transparent PNG renders unreadable in + # dark-mode clients, which bleed their own background through. + def generate_og_variant(original_path) + dest_path = file_path(:og) + FileUtils.mkdir_p(File.dirname(dest_path)) + + if og_full_bleed?(original_path) + system("magick", "#{original_path}[0]", + "-resize", "#{OG_WIDTH}x#{OG_HEIGHT}^", + "-gravity", "center", "-extent", "#{OG_WIDTH}x#{OG_HEIGHT}", + "-background", OG_GROUND, "-alpha", "remove", "-alpha", "off", + *og_output_args, dest_path) + else + system(*og_template_command(dest_path)) + end + end + def generate_all_variants(original_path) if IMAGE_CONTENT_TYPES.include?(upload_content_type) generate_variants(original_path) elsif VECTOR_CONTENT_TYPES.include?(upload_content_type) generate_svg_variants(original_path) elsif RASTERIZED_CONTENT_TYPES.include?(upload_content_type) - generate_variants("#{original_path}[0]", + unless source_dimensions(original_path) + Rails.logger.warn("Asset##{id}: #{upload_file_name} has no rasterisable first page, skipping variants") + return false + end + + generate_variants("#{original_path}[0]", ["-background", "white", "-alpha", "remove", "-alpha", "off"]) + else + # Not displayable as an image at all: no variants, no card. + return false + end + + generate_og_variant(original_path) + true + end + + def source_dimensions(path) + out, status = Open3.capture2("magick", "identify", "-format", "%w %h", "#{path}[0]") + return nil unless status.success? + + width, height = out.split.map(&:to_i) + (width.positive? && height.positive?) ? [width, height] : nil + rescue Errno::ENOENT + nil + end + + # True when the source should fill the whole 1200x630 frame rather than + # sit on the template + def og_full_bleed?(path) + return false unless IMAGE_CONTENT_TYPES.include?(upload_content_type) + + dimensions = source_dimensions(path) + return false unless dimensions + + (dimensions[0].to_f / dimensions[1]) >= OG_CROP_RATIO + end + + # Fits the source into OG_SLOT, borders it, drops a shadow behind it, then + # pastes the result onto the template. OG_SLOT is the paper rectangle + # without the shadow, so the layer is padded by OG_SHADOW_MARGIN on every + # side and pasted that much up and to the left -- which keeps the paper + # itself exactly where it was measured. + def og_template_command(dest_path) + rasterized = RASTERIZED_CONTENT_TYPES.include?(upload_content_type) + original = file_path(:original) + + # -density must precede the input filename to affect PDF rasterisation, + # which is why this cannot be a STYLES entry: that loop puts all + # arguments after the input. 150dpi gives A4 about 1240x1754, ample for + # a 490px-tall thumbnail. + density = rasterized ? ["-density", "150"] : [] + source = rasterized ? "#{original}[0]" : original + flatten = rasterized ? ["-background", "white", "-alpha", "remove", "-alpha", "off"] : [] + + paper = "#{OG_SLOT[:width]}x#{OG_SLOT[:height]}" + envelope = "#{OG_SLOT[:width] + 2 * OG_SHADOW_MARGIN}x#{OG_SLOT[:height] + 2 * OG_SHADOW_MARGIN}" + paste_x = OG_SLOT[:x] - OG_SHADOW_MARGIN + paste_y = OG_SLOT[:y] - OG_SHADOW_MARGIN + + ["magick", OG_TEMPLATE.to_s, + "(", *density, source, *flatten, + # > means a source smaller than the slot stays at native size + # rather than being upscaled into softness. + "-resize", "#{paper}>", + "-bordercolor", OG_PAPER_EDGE, "-border", OG_PAPER_BORDER.to_s, + "(", "+clone", "-background", "black", "-shadow", OG_SHADOW, ")", + "+swap", "-background", "none", "-layers", "merge", "+repage", + "-background", "none", "-gravity", "center", "-extent", envelope, + ")", + "-gravity", "northwest", "-geometry", "+#{paste_x}+#{paste_y}", + "-composite", + "-background", OG_GROUND, "-alpha", "remove", "-alpha", "off", + *og_output_args, dest_path] + end + + def og_output_args + if RASTERIZED_CONTENT_TYPES.include?(upload_content_type) || + VECTOR_CONTENT_TYPES.include?(upload_content_type) + # PNG without palette reduction: the drop shadow is a smooth alpha + # ramp, and quantising it dithers into visible speckle along the + # edge. JPEG is no better here, since it rings around the small + # black-on-white text of a document page. + ["-define", "png:compression-level=9", "-strip"] + else + # -strip also removes EXIF, so a headline photograph's camera model + # and GPS coordinates do not travel to every platform that fetches + # the card. + ["-quality", "82", "-sampling-factor", "4:4:4", "-strip"] end end + def delete_upload_files dir = upload_root.join(id.to_s) FileUtils.rm_rf(dir) if Dir.exist?(dir) -- cgit v1.3