Prefer Minimagick for social image generation (#19826)
* Prefer Minimagick for social image generation * Update app/services/images/generate_social_image_magickally.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update app/services/images/generate_social_image_magickally.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update app/services/images/generate_social_image_magickally.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Reformat main file * Fix user tests * Finish up tests * Update app/services/images/generate_social_image_magickally.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update app/services/images/generate_social_image_magickally.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update app/services/images/generate_social_image_magickally.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update app/services/images/generate_social_image_magickally.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update app/services/images/generate_social_image_magickally.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update app/services/images/generate_social_image_magickally.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Change asset path syntax * Update app/services/images/generate_social_image_magickally.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update app/services/images/generate_social_image_magickally.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update app/services/images/generate_social_image_magickally.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update app/services/images/generate_social_image_magickally.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Refactor magickally file * Update spec/services/images/generate_social_image_magickally_spec.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update spec/services/images/generate_social_image_magickally_spec.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Fix style issues * Remove pure black * Test color swap * Update spec/services/images/generate_social_image_magickally_spec.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update spec/services/images/generate_social_image_magickally_spec.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update spec/services/images/generate_social_image_magickally_spec.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update spec/services/images/generate_social_image_magickally_spec.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update spec/services/images/generate_social_image_magickally_spec.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update spec/services/images/generate_social_image_magickally_spec.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update spec/services/images/generate_social_image_magickally_spec.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Add more test coverage to service * Update spec/services/images/generate_social_image_magickally_spec.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Fix doubled spec name * Update spec/services/images/generate_social_image_magickally_spec.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update spec/services/images/generate_social_image_magickally_spec.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update spec/services/images/generate_social_image_magickally_spec.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update spec/services/images/generate_social_image_magickally_spec.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update spec/services/images/generate_social_image_magickally_spec.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update spec/services/images/generate_social_image_magickally_spec.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Add upload spec * Update spec/services/images/generate_social_image_magickally_spec.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update spec/services/images/generate_social_image_magickally_spec.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Swap out magickally in social image worker * Add test stub and rescue * Update spec/rails_helper.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Fix regex * Add cache busting and error logging * Update app/services/images/generate_social_image_magickally.rb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Fix style issues * Add more test coverage * Add feature flag --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
991472e4d6
commit
c9af090af0
17 changed files with 648 additions and 1 deletions
BIN
app/assets/fonts/Roboto-Bold.ttf
Normal file
BIN
app/assets/fonts/Roboto-Bold.ttf
Normal file
Binary file not shown.
BIN
app/assets/fonts/Roboto-Medium.ttf
Normal file
BIN
app/assets/fonts/Roboto-Medium.ttf
Normal file
Binary file not shown.
BIN
app/assets/images/rounded_mask.png
Normal file
BIN
app/assets/images/rounded_mask.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 686 B |
BIN
app/assets/images/social_template.png
Normal file
BIN
app/assets/images/social_template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 166 B |
|
|
@ -205,6 +205,7 @@ class Article < ApplicationRecord
|
|||
after_save :create_conditional_autovomits
|
||||
after_save :bust_cache
|
||||
after_save :collection_cleanup
|
||||
after_save :generate_social_image
|
||||
|
||||
after_update_commit :update_notifications, if: proc { |article|
|
||||
article.notifications.any? && !article.saved_changes.empty?
|
||||
|
|
@ -984,6 +985,17 @@ class Article < ApplicationRecord
|
|||
touch_actor_latest_article_updated_at(destroying: destroying)
|
||||
end
|
||||
|
||||
def generate_social_image
|
||||
return unless FeatureFlag.enabled?(:minimagick_social_images)
|
||||
|
||||
return if main_image.present?
|
||||
|
||||
change = saved_change_to_attribute?(:title) || saved_change_to_attribute?(:published_at)
|
||||
return unless (change || social_image.blank?) && published
|
||||
|
||||
Images::SocialImageWorker.perform_async(id, self.class.name)
|
||||
end
|
||||
|
||||
def calculate_base_scores
|
||||
self.hotness_score = 1000 if hotness_score.blank?
|
||||
end
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class Organization < ApplicationRecord
|
|||
before_save :generate_secret
|
||||
|
||||
after_save :bust_cache
|
||||
after_save :generate_social_images
|
||||
|
||||
after_update_commit :conditionally_update_articles
|
||||
after_destroy_commit :bust_cache
|
||||
|
|
@ -132,6 +133,15 @@ class Organization < ApplicationRecord
|
|||
|
||||
private
|
||||
|
||||
def generate_social_images
|
||||
return unless FeatureFlag.enabled?(:minimagick_social_images)
|
||||
|
||||
change = saved_change_to_attribute?(:name) || saved_change_to_attribute?(:profile_image)
|
||||
return unless change && articles.published.size.positive?
|
||||
|
||||
Images::SocialImageWorker.perform_async(id, self.class.name)
|
||||
end
|
||||
|
||||
def evaluate_markdown
|
||||
self.cta_processed_html = MarkdownProcessor::Parser.new(cta_body_markdown).evaluate_limited_markdown
|
||||
end
|
||||
|
|
|
|||
|
|
@ -233,6 +233,7 @@ class User < ApplicationRecord
|
|||
after_create_commit :send_welcome_notification
|
||||
|
||||
after_save :create_conditional_autovomits
|
||||
after_save :generate_social_images
|
||||
after_commit :subscribe_to_mailchimp_newsletter
|
||||
after_commit :bust_cache
|
||||
|
||||
|
|
@ -582,6 +583,15 @@ class User < ApplicationRecord
|
|||
|
||||
private
|
||||
|
||||
def generate_social_images
|
||||
return unless FeatureFlag.enabled?(:minimagick_social_images)
|
||||
|
||||
change = saved_change_to_attribute?(:name) || saved_change_to_attribute?(:profile_image)
|
||||
return unless change && articles.published.size.positive?
|
||||
|
||||
Images::SocialImageWorker.perform_async(id, self.class.name)
|
||||
end
|
||||
|
||||
def create_users_settings_and_notification_settings_records
|
||||
self.setting = Users::Setting.create
|
||||
self.notification_setting = Users::NotificationSetting.create
|
||||
|
|
|
|||
196
app/services/images/generate_social_image_magickally.rb
Normal file
196
app/services/images/generate_social_image_magickally.rb
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
module Images
|
||||
MEDIUM_FONT_PATH = Rails.root.join("app/assets/fonts/Roboto-Medium.ttf").freeze
|
||||
BOLD_FONT_PATH = Rails.root.join("app/assets/fonts/Roboto-Bold.ttf").freeze
|
||||
TEMPLATE_PATH = Rails.root.join("app/assets/images/social_template.png").freeze
|
||||
ROUNDED_MASK_PATH = Rails.root.join("app/assets/images/rounded_mask.png").freeze
|
||||
|
||||
class GenerateSocialImageMagickally
|
||||
def self.call(resource = nil)
|
||||
new(resource).call
|
||||
end
|
||||
|
||||
def initialize(resource)
|
||||
@resource = resource
|
||||
@logo_url = Settings::General.logo_png
|
||||
end
|
||||
|
||||
def call
|
||||
if @resource.is_a?(Article)
|
||||
@user = @resource.user
|
||||
read_files
|
||||
url = generate_magickally(title: @resource.title,
|
||||
date: @resource.readable_publish_date,
|
||||
author_name: @user.name,
|
||||
color: @user.setting.brand_color1)
|
||||
@resource.update_column(:social_image, url)
|
||||
## We only need to bust article. All else can fade naturally
|
||||
EdgeCache::BustArticle.call(@resource)
|
||||
elsif @resource.is_a?(User)
|
||||
@user = @resource
|
||||
read_files
|
||||
@resource.articles.published.where(organization_id: nil, main_image: nil).find_each do |article|
|
||||
url = generate_magickally(title: article.title,
|
||||
date: article.readable_publish_date,
|
||||
author_name: @user.name,
|
||||
color: @user.setting.brand_color1)
|
||||
article.update_column(:social_image, url)
|
||||
end
|
||||
else # Organization
|
||||
@user = @resource
|
||||
read_files
|
||||
@resource.articles.published.where(main_image: nil).find_each do |article|
|
||||
url = generate_magickally(title: article.title,
|
||||
date: article.readable_publish_date,
|
||||
author_name: @user.name,
|
||||
color: @user.bg_color_hex)
|
||||
article.update_column(:social_image, url)
|
||||
end
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error(e)
|
||||
Honeybadger.notify(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_magickally(title: nil, date: nil, author_name: nil, color: nil)
|
||||
result = draw_stripe(color)
|
||||
result = add_logo(result)
|
||||
result = add_text(result, title, date, author_name)
|
||||
result = add_profile_image(result)
|
||||
upload_result(result)
|
||||
end
|
||||
|
||||
def draw_stripe(color)
|
||||
color = "#111212" if color == "#000000" # pure black has minimagick side effects
|
||||
@background_image.combine_options do |c|
|
||||
c.fill color
|
||||
c.draw "rectangle 0,0 1000,24" # adjust width according to your image width
|
||||
end
|
||||
end
|
||||
|
||||
def add_logo(result)
|
||||
if @logo_image
|
||||
# Add white stroke to the overlay image
|
||||
@logo_image.combine_options do |c|
|
||||
c.stroke "white"
|
||||
c.strokewidth "4"
|
||||
c.fill "none"
|
||||
c.draw "rectangle 0,0 1000,1000" # adjust as needed based on image size
|
||||
end
|
||||
|
||||
# Resize the overlay image
|
||||
@logo_image.resize "64x64"
|
||||
|
||||
result = @background_image.composite(@logo_image) do |c|
|
||||
c.compose "Over" # OverCompositeOp
|
||||
c.geometry "+850+372" # move the overlay to the top left
|
||||
end
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def add_text(result, title, date, author_name)
|
||||
title = wrap_text(title)
|
||||
font_size = calculate_font_size(title)
|
||||
|
||||
result.combine_options do |c|
|
||||
c.gravity "West" # Set the origin for the text at the top left corner
|
||||
c.pointsize font_size.to_s
|
||||
c.draw "text 80,-39 '#{title}'" # Start drawing text 90 from the left and slightly north
|
||||
c.fill "black"
|
||||
c.font BOLD_FONT_PATH.to_s
|
||||
end
|
||||
|
||||
result.combine_options do |c|
|
||||
c.gravity "Southwest"
|
||||
c.pointsize "32"
|
||||
c.draw "text 156,88 '#{author_name}'" # adjust coordinates as needed
|
||||
c.fill "black"
|
||||
c.font MEDIUM_FONT_PATH.to_s
|
||||
end
|
||||
|
||||
result.combine_options do |c|
|
||||
c.gravity "Southwest"
|
||||
c.pointsize "26"
|
||||
c.draw "text 156,60 '#{date}'" # adjust coordinates as needed
|
||||
c.fill "#525252"
|
||||
end
|
||||
end
|
||||
|
||||
def add_profile_image(result)
|
||||
profile_image_size = "64x64"
|
||||
profile_image_location = "+80+63"
|
||||
# Add subtext and author image
|
||||
@author_image.resize profile_image_size
|
||||
result = result.composite(@author_image) do |c|
|
||||
c.compose "Over"
|
||||
c.gravity "Southwest"
|
||||
c.geometry profile_image_location
|
||||
end
|
||||
|
||||
@rounded_mask.resize profile_image_size
|
||||
|
||||
result.composite(@rounded_mask) do |c|
|
||||
c.compose "Over"
|
||||
c.gravity "Southwest"
|
||||
c.geometry profile_image_location
|
||||
end
|
||||
end
|
||||
|
||||
def upload_result(result)
|
||||
tempfile = Tempfile.new(["output", ".png"])
|
||||
result.write tempfile.path
|
||||
image_uploader = ArticleImageUploader.new.tap do |uploader|
|
||||
uploader.store!(tempfile)
|
||||
end
|
||||
# Don't forget to close and unlink (delete) the tempfile after you're done with it.
|
||||
tempfile.close
|
||||
tempfile.unlink
|
||||
|
||||
# Return the uploaded url
|
||||
image_uploader.url
|
||||
end
|
||||
|
||||
attr_reader :resource
|
||||
|
||||
def calculate_font_size(text)
|
||||
text_length = text.length
|
||||
|
||||
if text_length < 18
|
||||
88
|
||||
elsif text_length < 40
|
||||
77
|
||||
elsif text_length < 55
|
||||
65
|
||||
elsif text_length < 70
|
||||
60
|
||||
else
|
||||
50
|
||||
end
|
||||
end
|
||||
|
||||
def wrap_text(text)
|
||||
line_width = if text.length < 40
|
||||
20
|
||||
elsif text.length < 70
|
||||
27
|
||||
else
|
||||
35
|
||||
end
|
||||
text.split("\n").map do |line|
|
||||
line.length > line_width ? line.gsub(/(.{1,#{line_width}})(\s+|$)/, "\\1\n").strip : line
|
||||
end * "\n"
|
||||
end
|
||||
|
||||
def read_files
|
||||
# These are files we can open once for all the images we are generating within the loop.
|
||||
@background_image = MiniMagick::Image.open(ActionController::Base.helpers.asset_path(TEMPLATE_PATH))
|
||||
@logo_image = MiniMagick::Image.open(@logo_url) if @logo_url.present?
|
||||
image = @user.profile_image_90
|
||||
author_image_url = image.start_with?("http") ? image : Images::Profile::BACKUP_LINK
|
||||
@author_image = MiniMagick::Image.open(author_image_url)
|
||||
@rounded_mask = MiniMagick::Image.open(ActionController::Base.helpers.asset_path(ROUNDED_MASK_PATH))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -41,8 +41,8 @@ module Articles
|
|||
end
|
||||
|
||||
def user_defined_image
|
||||
return article.social_image if article.social_image.present?
|
||||
return article.main_image if article.main_image.present?
|
||||
return article.social_image if article.social_image.present?
|
||||
return article.video_thumbnail_url if article.video_thumbnail_url.present?
|
||||
end
|
||||
end
|
||||
|
|
|
|||
12
app/workers/images/social_image_worker.rb
Normal file
12
app/workers/images/social_image_worker.rb
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
module Images
|
||||
class SocialImageWorker
|
||||
include Sidekiq::Job
|
||||
|
||||
sidekiq_options queue: :medium_priority, retry: 5
|
||||
|
||||
def perform(id, class_name)
|
||||
object = class_name.constantize.find(id)
|
||||
Images::GenerateSocialImageMagickally.call(object)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
module DataUpdateScripts
|
||||
class AddMinimagickSocialImagesFeatureFlag
|
||||
def run
|
||||
FeatureFlag.add :minimagick_social_images
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1443,4 +1443,42 @@ RSpec.describe Article do
|
|||
expect(Languages::Detection).not_to have_received(:call)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#generate_social_image" do
|
||||
before do
|
||||
allow(Images::SocialImageWorker).to receive(:perform_async)
|
||||
end
|
||||
|
||||
context "when title or published_at attribute changes and published is true" do
|
||||
it "triggers the Images::SocialImageWorker" do
|
||||
article.body_markdown = "---\ntitle: New Title #{rand(1_000)}\npublished: true\n---\n\n# Hello World"
|
||||
article.main_image = nil
|
||||
article.save
|
||||
expect(Images::SocialImageWorker).to have_received(:perform_async)
|
||||
end
|
||||
end
|
||||
|
||||
context "when attributes have changed, but main image is present" do
|
||||
it "does not trigger the Images::SocialImageWorker" do
|
||||
article.body_markdown = "---\ntitle: New Title #{rand(1_000)}\ncover_image: https://example.com/i.jpg\npublished: true\n---\n\n# Hello World"
|
||||
article.save
|
||||
expect(Images::SocialImageWorker).not_to have_received(:perform_async)
|
||||
end
|
||||
end
|
||||
|
||||
context "when neither title nor published_at attribute changes" do
|
||||
it "does not trigger the Images::SocialImageWorker" do
|
||||
article.save
|
||||
expect(Images::SocialImageWorker).not_to have_received(:perform_async)
|
||||
end
|
||||
end
|
||||
|
||||
context "when title or published_at attribute changes but published is false" do
|
||||
it "does not trigger the Images::SocialImageWorker" do
|
||||
article.body_markdown = "---\ntitle: New Title #{rand(1_000)}\npublished: false\n---\n\n# Hello World"
|
||||
article.save
|
||||
expect(Images::SocialImageWorker).not_to have_received(:perform_async)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -391,6 +391,37 @@ RSpec.describe Organization do
|
|||
end
|
||||
end
|
||||
|
||||
describe "#generate_social_images" do
|
||||
before do
|
||||
allow(Images::SocialImageWorker).to receive(:perform_async)
|
||||
create(:article, organization: organization)
|
||||
end
|
||||
|
||||
context "when the name or profile_image has changed and the organization has articles" do
|
||||
it "calls SocialImageWorker.perform_async" do
|
||||
organization.name = "New name for this org!"
|
||||
organization.save
|
||||
expect(Images::SocialImageWorker).to have_received(:perform_async)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the name or profile_image has not changed or the organization has no articles" do
|
||||
it "does not call SocialImageWorker.perform_async" do
|
||||
expect(Images::SocialImageWorker).not_to receive(:perform_async)
|
||||
organization.save
|
||||
end
|
||||
end
|
||||
|
||||
context "when the name or profile_image has changed and the organization has no articles" do
|
||||
it "does not call SocialImageWorker.perform_async" do
|
||||
organization.articles.destroy_all
|
||||
organization.name = "New name for this org!!"
|
||||
organization.save
|
||||
expect(Images::SocialImageWorker).not_to receive(:perform_async)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#bust_cache" do
|
||||
context "when a new user is added to the organization" do
|
||||
let(:user) { create(:user) }
|
||||
|
|
|
|||
|
|
@ -926,4 +926,26 @@ RSpec.describe User do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#generate_social_images" do
|
||||
before do
|
||||
create(:article, user: user)
|
||||
allow(Images::SocialImageWorker).to receive(:perform_async)
|
||||
end
|
||||
|
||||
context "when the name or profile_image has changed and the user has articles" do
|
||||
it "calls SocialImageWorker.perform_async" do
|
||||
user.name = "New name for this user!"
|
||||
user.save
|
||||
expect(Images::SocialImageWorker).to have_received(:perform_async)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the name or profile_image has not changed or the user has no articles" do
|
||||
it "does not call SocialImageWorker.perform_async" do
|
||||
user.save
|
||||
expect(Images::SocialImageWorker).not_to have_received(:perform_async)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ RSpec.configure do |config|
|
|||
# "Please stub a default value first if message might be received with other args as well."
|
||||
allow(FeatureFlag).to receive(:enabled?).and_call_original
|
||||
allow(FeatureFlag).to receive(:enabled?).with(:connect).and_return(true)
|
||||
allow(FeatureFlag).to receive(:enabled?).with(:minimagick_social_images, any_args).and_return(true)
|
||||
end
|
||||
|
||||
config.around(:each, :flaky) do |ex|
|
||||
|
|
@ -217,6 +218,13 @@ RSpec.configure do |config|
|
|||
"Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3",
|
||||
"User-Agent" => "Ruby"
|
||||
}).to_return(status: 200, body: "", headers: {})
|
||||
stub_request(:get, /assets\/icon/)
|
||||
.with(headers:
|
||||
{
|
||||
"Accept" => "*/*",
|
||||
"Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3",
|
||||
"User-Agent" => "Ruby"
|
||||
}).to_return(status: 200, body: "", headers: {})
|
||||
|
||||
allow(Settings::Community).to receive(:community_description).and_return("Some description")
|
||||
allow(Settings::UserExperience).to receive(:public).and_return(true)
|
||||
|
|
|
|||
|
|
@ -215,6 +215,18 @@ RSpec.describe "StoriesShow" do
|
|||
get article.path
|
||||
expect(response.body).not_to include("noindex")
|
||||
end
|
||||
|
||||
it "renders og:image with main image if present ahead of social" do
|
||||
article = create(:article, with_main_image: true, social_image: "https://example.com/image.jpg")
|
||||
get article.path
|
||||
expect(response.body).to include(%(property="og:image" content="#{article.main_image}"))
|
||||
end
|
||||
|
||||
it "renders og:image with social if present and main image not so much" do
|
||||
article = create(:article, with_main_image: false, social_image: "https://example.com/image.jpg")
|
||||
get article.path
|
||||
expect(response.body).to include(%(property="og:image" content="#{article.social_image}"))
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /:username (org)" do
|
||||
|
|
|
|||
289
spec/services/images/generate_social_image_magickally_spec.rb
Normal file
289
spec/services/images/generate_social_image_magickally_spec.rb
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Images::GenerateSocialImageMagickally, type: :model do
|
||||
let(:user) { create(:user) }
|
||||
let!(:article) { create(:article, user_id: user.id, with_main_image: false) }
|
||||
let(:organization) { create(:organization) }
|
||||
let!(:second_article) { create(:article, user_id: user.id, organization_id: organization.id, with_main_image: false) }
|
||||
let(:background_image) { instance_double(MiniMagick::Image) }
|
||||
|
||||
# rubocop:disable Style/Send
|
||||
# rubocop:disable Layout/LineLength
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
# rubocop:disable RSpec/ContextWording
|
||||
# rubocop:disable Style/Semicolon
|
||||
# rubocop:disable RSpec/NestedGroups
|
||||
describe ".call" do
|
||||
context "when resource is an Article" do
|
||||
let(:generator) { described_class.new(article) }
|
||||
|
||||
before do
|
||||
allow(described_class).to receive(:new).and_return(generator)
|
||||
allow(generator).to receive(:read_files)
|
||||
allow(generator).to receive(:add_logo)
|
||||
allow(generator).to receive(:add_text)
|
||||
allow(generator).to receive(:add_profile_image)
|
||||
allow(generator).to receive(:upload_result).and_return("image_url")
|
||||
allow(generator).to receive(:generate_magickally)
|
||||
end
|
||||
|
||||
it "calls the class methods" do
|
||||
described_class.call(article)
|
||||
expect(generator).to have_received(:generate_magickally).once
|
||||
end
|
||||
|
||||
it "updates article to have social image" do
|
||||
allow(generator).to receive(:generate_magickally).and_return("https://www.example.com")
|
||||
described_class.call(article)
|
||||
expect(article.reload.social_image).to eq("https://www.example.com")
|
||||
end
|
||||
|
||||
it "converts black color to off-black" do
|
||||
allow(background_image).to receive(:combine_options).with(any_args) do |&block|
|
||||
command = MiniMagick::CommandBuilder.new(:mogrify)
|
||||
block.call(command)
|
||||
expect(command.args.join(" ")).to include("fill '#111212'")
|
||||
end
|
||||
|
||||
described_class.call(article)
|
||||
end
|
||||
|
||||
it "busts article cache" do
|
||||
allow(EdgeCache::BustArticle).to receive(:call).with(article)
|
||||
described_class.call(article)
|
||||
end
|
||||
end
|
||||
|
||||
context "when resource is a User" do
|
||||
let(:generator) { described_class.new(user) }
|
||||
|
||||
before do
|
||||
allow(described_class).to receive(:new).and_return(generator)
|
||||
allow(generator).to receive(:read_files)
|
||||
allow(generator).to receive(:generate_magickally)
|
||||
allow(user).to receive(:profile_image_90).and_return("https://www.example.com/image.png")
|
||||
end
|
||||
|
||||
it "calls the class methods for each published article" do
|
||||
described_class.call(user)
|
||||
expect(generator).to have_received(:generate_magickally).once
|
||||
.with(
|
||||
title: article.title,
|
||||
date: article.readable_publish_date,
|
||||
author_name: user.name,
|
||||
color: user.setting.brand_color1,
|
||||
)
|
||||
end
|
||||
|
||||
it "updates article to have social image" do
|
||||
allow(generator).to receive(:generate_magickally).and_return("https://www.example.com")
|
||||
described_class.call(user)
|
||||
expect(article.reload.social_image).to eq("https://www.example.com")
|
||||
end
|
||||
end
|
||||
|
||||
context "when resource is an Organization" do
|
||||
let(:generator) { described_class.new(organization) }
|
||||
|
||||
before do
|
||||
allow(described_class).to receive(:new).and_return(generator)
|
||||
allow(generator).to receive(:read_files)
|
||||
end
|
||||
|
||||
it "calls the class methods for each published article" do
|
||||
allow(generator).to receive(:generate_magickally)
|
||||
described_class.call(organization)
|
||||
expect(generator).to have_received(:generate_magickally).once
|
||||
end
|
||||
|
||||
it "updates article to have social image" do
|
||||
allow(generator).to receive(:generate_magickally).and_return("https://www.example.com")
|
||||
described_class.call(organization)
|
||||
expect(generator).to have_received(:generate_magickally).once
|
||||
expect(second_article.reload.social_image).to eq("https://www.example.com")
|
||||
end
|
||||
end
|
||||
|
||||
context "when calculate_font_size" do
|
||||
let(:generator) { described_class.new(article) }
|
||||
|
||||
it "returns the correct font size for short text" do
|
||||
expect(generator.send(:calculate_font_size, "short text")).to eq(88)
|
||||
end
|
||||
|
||||
it "returns the correct font size for medium text" do
|
||||
medium_text = "This is a slightly longer text."
|
||||
expect(generator.send(:calculate_font_size, medium_text)).to eq(77)
|
||||
end
|
||||
|
||||
it "returns the correct font size for medium-to-long text" do
|
||||
medium_long_text = "This is a slightly longer text. Slightly longer."
|
||||
expect(generator.send(:calculate_font_size, medium_long_text)).to eq(65)
|
||||
end
|
||||
|
||||
it "returns the correct font size for medium-to-long text" do
|
||||
almost_long_text = "This is a slightly longer text. Slightly longer than the last."
|
||||
expect(generator.send(:calculate_font_size, almost_long_text)).to eq(60)
|
||||
end
|
||||
|
||||
it "returns the correct font size for long text" do
|
||||
long_text = "This is a very long text that is definitely more than 70 characters long
|
||||
and should return a smaller font size"
|
||||
expect(generator.send(:calculate_font_size, long_text)).to eq(50)
|
||||
end
|
||||
end
|
||||
|
||||
context "wrap_text" do
|
||||
let(:generator) { described_class.new(article) }
|
||||
|
||||
it "returns the same text when the text length is less than 40" do
|
||||
text = "This is a short text"
|
||||
expect(generator.send(:wrap_text, text)).to eq(text)
|
||||
end
|
||||
|
||||
it "returns wrapped text when the text length is between 40 and 70" do
|
||||
text = "This is a slightly longer text that is about 50 characters long and should be wrapped"
|
||||
wrapped_text = "This is a slightly longer text that\nis about 50 characters long and\nshould be wrapped"
|
||||
expect(generator.send(:wrap_text, text)).to eq(wrapped_text)
|
||||
end
|
||||
|
||||
it "returns wrapped text when the text length is more than 70" do
|
||||
text = "This is a very long text that is definitely more than 70 characters long and should be wrapped over multiple lines"
|
||||
wrapped_text = "This is a very long text that is\ndefinitely more than 70 characters\nlong and should be wrapped over\nmultiple lines"
|
||||
expect(generator.send(:wrap_text, text)).to eq(wrapped_text)
|
||||
end
|
||||
end
|
||||
|
||||
context "add_text" do
|
||||
let(:generator) { described_class.new(article) }
|
||||
let(:result_image) { double(MiniMagick::Tool::Convert) }
|
||||
|
||||
before do
|
||||
allow(generator).to receive(:calculate_font_size).and_return(40)
|
||||
allow(generator).to receive(:wrap_text).and_return("Wrapped Text")
|
||||
allow(result_image).to receive(:combine_options) { |&block| block.call(result_image); result_image }
|
||||
allow(result_image).to receive(:gravity)
|
||||
allow(result_image).to receive(:pointsize)
|
||||
allow(result_image).to receive(:draw)
|
||||
allow(result_image).to receive(:fill)
|
||||
allow(result_image).to receive(:font)
|
||||
generator.instance_variable_set(:@background_image, result_image)
|
||||
end
|
||||
|
||||
it "adds title, date, and author_name text to the image" do
|
||||
generator.send(:add_text, result_image, "title", "date", "author_name")
|
||||
expect(result_image).to have_received(:combine_options).exactly(3).times
|
||||
end
|
||||
end
|
||||
|
||||
context "add_profile_image" do
|
||||
let(:user) { create(:user) }
|
||||
let(:article) { create(:article, user_id: user.id, with_main_image: false) }
|
||||
let(:generator) { described_class.new(article) }
|
||||
let(:result_image) { double(MiniMagick::Tool::Convert) }
|
||||
let(:author_image) { double(MiniMagick::Tool::Convert) }
|
||||
let(:rounded_mask) { double(MiniMagick::Tool::Convert) }
|
||||
|
||||
before do
|
||||
allow(result_image).to receive(:composite).and_return(result_image)
|
||||
allow(author_image).to receive(:resize)
|
||||
allow(rounded_mask).to receive(:resize)
|
||||
|
||||
generator.instance_variable_set(:@background_image, result_image)
|
||||
generator.instance_variable_set(:@author_image, author_image)
|
||||
generator.instance_variable_set(:@rounded_mask, rounded_mask)
|
||||
end
|
||||
|
||||
it "adds the profile image and rounded mask to the image" do
|
||||
generator.send(:add_profile_image, result_image)
|
||||
expect(author_image).to have_received(:resize).with("64x64")
|
||||
expect(rounded_mask).to have_received(:resize).with("64x64")
|
||||
expect(result_image).to have_received(:composite).twice
|
||||
end
|
||||
end
|
||||
|
||||
context "add_logo" do
|
||||
let(:user) { create(:user) }
|
||||
let(:article) { create(:article, user_id: user.id, with_main_image: false) }
|
||||
let(:generator) { described_class.new(article) }
|
||||
let(:result_image) { double(MiniMagick::Tool::Convert) }
|
||||
let(:logo_image) { double(MiniMagick::Tool::Convert) }
|
||||
|
||||
before do
|
||||
allow(result_image).to receive(:composite).and_return(result_image)
|
||||
allow(logo_image).to receive(:combine_options).and_yield(logo_image)
|
||||
allow(logo_image).to receive(:resize)
|
||||
allow(logo_image).to receive(:stroke)
|
||||
allow(logo_image).to receive(:strokewidth)
|
||||
allow(logo_image).to receive(:fill)
|
||||
allow(logo_image).to receive(:draw)
|
||||
|
||||
generator.instance_variable_set(:@background_image, result_image)
|
||||
generator.instance_variable_set(:@logo_image, logo_image)
|
||||
end
|
||||
|
||||
context "when logo image is present" do
|
||||
it "adds the logo to the image" do
|
||||
generator.send(:add_logo, result_image)
|
||||
|
||||
expect(logo_image).to have_received(:combine_options)
|
||||
expect(logo_image).to have_received(:stroke).with("white")
|
||||
expect(logo_image).to have_received(:strokewidth).with("4")
|
||||
expect(logo_image).to have_received(:fill).with("none")
|
||||
expect(logo_image).to have_received(:draw).with("rectangle 0,0 1000,1000")
|
||||
expect(logo_image).to have_received(:resize).with("64x64")
|
||||
expect(result_image).to have_received(:composite).with(logo_image)
|
||||
end
|
||||
end
|
||||
|
||||
context "when logo image is not present" do
|
||||
before do
|
||||
generator.instance_variable_set(:@logo_image, nil)
|
||||
end
|
||||
|
||||
it "does not attempt to add the logo to the image" do
|
||||
generator.send(:add_logo, result_image)
|
||||
expect(result_image).not_to have_received(:composite)
|
||||
end
|
||||
end
|
||||
|
||||
context "upload_result" do
|
||||
let(:generator) { described_class.new(article) }
|
||||
let(:result_image) { double(MiniMagick::Tool::Convert) }
|
||||
let(:tempfile) { instance_double(Tempfile, path: "/tmp/output.png") }
|
||||
let(:uploader) { instance_double(ArticleImageUploader) }
|
||||
|
||||
before do
|
||||
allow(Tempfile).to receive(:new).and_return(tempfile)
|
||||
allow(tempfile).to receive(:close)
|
||||
allow(tempfile).to receive(:unlink)
|
||||
allow(result_image).to receive(:write)
|
||||
|
||||
allow(ArticleImageUploader).to receive(:new).and_return(uploader)
|
||||
allow(uploader).to receive(:store!)
|
||||
allow(uploader).to receive(:url).and_return("http://example.com/social_image.png")
|
||||
|
||||
generator.instance_variable_set(:@background_image, result_image)
|
||||
end
|
||||
|
||||
it "creates a tempfile, writes the image, uploads the image, then cleans up the tempfile" do
|
||||
expect(generator.send(:upload_result, result_image)).to eq "http://example.com/social_image.png"
|
||||
|
||||
expect(Tempfile).to have_received(:new).with(["output", ".png"])
|
||||
expect(result_image).to have_received(:write).with(tempfile.path)
|
||||
expect(ArticleImageUploader).to have_received(:new)
|
||||
expect(uploader).to have_received(:store!).with(tempfile)
|
||||
expect(tempfile).to have_received(:close)
|
||||
expect(tempfile).to have_received(:unlink)
|
||||
expect(uploader).to have_received(:url)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Style/Send
|
||||
# rubocop:enable RSpec/VerifiedDoubles
|
||||
# rubocop:enable Layout/LineLength
|
||||
# rubocop:enable RSpec/ContextWording
|
||||
# rubocop:enable Style/Semicolon
|
||||
# rubocop:enable RSpec/NestedGroups
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue