diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/models/concerns/file_attachment.rb | 166 |
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 @@ | |||
| 1 | require "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 | ||
| 22 | module FileAttachment | 22 | module 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) |
