package org.wikipedia.page.snippet;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.Shader;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.content.ContextCompat;
import android.support.v4.graphics.drawable.DrawableCompat;
import android.text.Layout;
import android.text.Spanned;
import android.text.StaticLayout;
import android.text.TextPaint;
import android.text.TextUtils;
import org.wikipedia.R;
import org.wikipedia.gallery.ImageLicense;
import org.wikipedia.util.L10nUtil;
import org.wikipedia.util.StringUtil;
import static android.text.Layout.Alignment.ALIGN_NORMAL;
import static android.text.Layout.Alignment.ALIGN_OPPOSITE;
/**
* Creator and holder of a Bitmap which is comprised of an optional lead image, a title,
* optional description, text, the Wikipedia wordmark, and some license icons.
*
* Creates a device-independent bitmap object; all dimension values are in px, not dp.
*/
public final class SnippetImage {
private static final int WIDTH = 640;
private static final int HEIGHT = 360;
private static final int BOTTOM_PADDING = 25;
private static final int HORIZONTAL_PADDING = 30;
private static final int TOP_PADDING = 22;
private static final int TEXT_WIDTH = WIDTH - 2 * HORIZONTAL_PADDING;
private static final int DESCRIPTION_WIDTH = 360;
private static final int ICONS_WIDTH = 16;
private static final int ICONS_HEIGHT = 16;
private static final float SPACING_MULTIPLIER = 1.0f;
private static final Typeface SERIF = Typeface.create("serif", Typeface.NORMAL);
private static final int QUARTER = 4;
/**
* Creates a card image usable for sharing and the preview of the same.
* If we have a leadImageBitmap the use that as the background. If not then
* just use a black background.
*/
public static Bitmap getSnippetImage(@NonNull Context context, @Nullable Bitmap leadImageBitmap,
@NonNull String title, @Nullable String description,
@NonNull CharSequence textSnippet,
@NonNull ImageLicense license) {
Bitmap resultBitmap = drawBackground(leadImageBitmap, license);
Canvas canvas = new Canvas(resultBitmap);
if (leadImageBitmap != null) {
drawGradient(canvas);
}
Layout textLayout = drawTextSnippet(canvas, textSnippet);
boolean isArticleRTL = textLayout.getParagraphDirection(0) == Layout.DIR_RIGHT_TO_LEFT;
drawLicenseIcons(context, leadImageBitmap, license, canvas, isArticleRTL);
int top = drawDescription(canvas, description, HEIGHT - BOTTOM_PADDING - ICONS_HEIGHT, isArticleRTL);
drawTitle(canvas, title, top, isArticleRTL);
if (L10nUtil.canLangUseImageForWikipediaWordmark(context)) {
drawWordmarkFromStaticImage(context, canvas, isArticleRTL);
} else {
drawWordmarkFromText(context, canvas, isArticleRTL);
}
return resultBitmap;
}
@NonNull private static Bitmap drawBackground(@Nullable Bitmap leadImageBitmap,
@NonNull ImageLicense license) {
Bitmap resultBitmap;
if (leadImageBitmap != null && license.hasLicenseInfo()) {
// use lead image
resultBitmap = scaleCropToFit(leadImageBitmap, WIDTH, HEIGHT);
} else {
resultBitmap = Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888);
final int backgroundColor = 0xff242438;
resultBitmap.eraseColor(backgroundColor);
}
return resultBitmap;
}
private static void drawGradient(@NonNull Canvas canvas) {
// draw a dark gradient over the image, so that the white text
// will stand out better against it.
final int gradientStartColor = 0x60000000;
final int gradientStopColor = 0xA0000000;
Shader shader = new LinearGradient(0, 0, 0, canvas.getHeight(), gradientStartColor,
gradientStopColor, Shader.TileMode.CLAMP);
Paint paint = new Paint();
paint.setShader(shader);
canvas.drawRect(new Rect(0, 0, canvas.getWidth(), canvas.getHeight()), paint);
}
@NonNull private static Layout drawTextSnippet(@NonNull Canvas canvas,
@NonNull CharSequence textSnippet) {
final int top = TOP_PADDING;
final int maxHeight = 225;
final int maxLines = 5;
final float maxFontSize = 195.0f;
final float minFontSize = 32.0f;
TextPaint textPaint = new TextPaint();
textPaint.setAntiAlias(true);
textPaint.setColor(Color.WHITE);
textPaint.setTextSize(maxFontSize);
textPaint.setStyle(Paint.Style.FILL);
textPaint.setTypeface(Typeface.DEFAULT_BOLD);
textPaint.setShadowLayer(1.0f, 1.0f, 1.0f, Color.GRAY);
StaticLayout textLayout = optimizeTextSize(
new TextLayoutParams(textSnippet, textPaint, TEXT_WIDTH, SPACING_MULTIPLIER),
maxHeight, maxLines, maxFontSize, minFontSize);
canvas.save();
int horizontalCenterOffset = top + (maxHeight - textLayout.getHeight()) / QUARTER;
canvas.translate(HORIZONTAL_PADDING, horizontalCenterOffset);
textLayout.draw(canvas);
canvas.restore();
return textLayout;
}
private static int drawDescription(@NonNull Canvas canvas, @Nullable String description,
int top, boolean isArticleRTL) {
final int marginBottom = 5;
final int maxHeight = 23;
final int maxLines = 2;
final float maxFontSize = 15.0f;
final float minFontSize = 10.0f;
if (TextUtils.isEmpty(description)) {
return top - marginBottom;
}
TextPaint textPaint = new TextPaint();
textPaint.setAntiAlias(true);
textPaint.setColor(Color.WHITE);
textPaint.setTextSize(maxFontSize);
textPaint.setStyle(Paint.Style.FILL);
textPaint.setShadowLayer(1.0f, 0.0f, 0.0f, Color.GRAY);
StaticLayout textLayout = optimizeTextSize(
new TextLayoutParams(description, textPaint, DESCRIPTION_WIDTH, SPACING_MULTIPLIER),
maxHeight, maxLines, maxFontSize, minFontSize);
int left = HORIZONTAL_PADDING;
if (isArticleRTL) {
left = WIDTH - HORIZONTAL_PADDING - textLayout.getWidth();
}
top = top - marginBottom - textLayout.getHeight();
canvas.save();
canvas.translate(left, top);
textLayout.draw(canvas);
canvas.restore();
return top;
}
private static void drawTitle(@NonNull Canvas canvas, @NonNull String title, int top,
boolean isArticleRTL) {
final int marginBottom = 0;
final int maxHeight = 70;
final int maxLines = 2;
final float maxFontSize = 30.0f;
final float spacingMultiplier = 0.7f;
TextPaint textPaint = new TextPaint();
textPaint.setAntiAlias(true);
textPaint.setColor(Color.WHITE);
textPaint.setTextSize(maxFontSize);
textPaint.setStyle(Paint.Style.FILL);
textPaint.setTypeface(SERIF);
textPaint.setShadowLayer(1.0f, 0.0f, 1.0f, Color.GRAY);
StaticLayout textLayout = optimizeTextSize(
new TextLayoutParams(title, textPaint, DESCRIPTION_WIDTH, spacingMultiplier),
maxHeight, maxLines, maxFontSize, maxFontSize);
int left = HORIZONTAL_PADDING;
if (isArticleRTL) {
left = WIDTH - HORIZONTAL_PADDING - textLayout.getWidth();
}
int marginBottomTotal = marginBottom;
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
// versions < 5.0 don't compensate for bottom margin correctly when line
// spacing is less than 1.0, so we'll compensate ourselves
final int marginBoost = 10;
marginBottomTotal += marginBoost;
}
top = top - marginBottomTotal - textLayout.getHeight();
canvas.save();
canvas.translate(left, top);
textLayout.draw(canvas);
canvas.restore();
}
private static void drawLicenseIcons(@NonNull Context context, @Nullable Bitmap leadImageBitmap,
@NonNull ImageLicense license, @NonNull Canvas canvas,
boolean isArticleRTL) {
final int bottom = SnippetImage.HEIGHT - SnippetImage.BOTTOM_PADDING;
final int top = bottom - SnippetImage.ICONS_HEIGHT;
int left = SnippetImage.HORIZONTAL_PADDING;
int right = left + SnippetImage.ICONS_WIDTH;
if (isArticleRTL) {
right = SnippetImage.WIDTH - SnippetImage.HORIZONTAL_PADDING;
left = right - SnippetImage.ICONS_WIDTH;
}
Drawable d = ContextCompat.getDrawable(context,
shouldDefaultToCCLicense(leadImageBitmap, license)
? R.drawable.ic_license_cc : license.getLicenseIcon());
d.setBounds(left, top, right, bottom);
d.draw(canvas);
}
/**
* Default to showing Creative Commons license icon for card as a whole if lead image is not present
* or will not be used due to a lack of licensing data.
*/
private static boolean shouldDefaultToCCLicense(@Nullable Bitmap leadImageBitmap,
@NonNull ImageLicense license) {
return leadImageBitmap == null || !license.hasLicenseInfo();
}
private static void drawWordmarkFromStaticImage(@NonNull Context context,
@NonNull Canvas canvas, boolean isArticleRTL) {
// scaling it a bit down from original 317x54px size
final int width = 130;
final int height = 22;
final int bottom = HEIGHT - BOTTOM_PADDING;
final int top = bottom - height;
Drawable d = ContextCompat.getDrawable(context, R.drawable.wp_wordmark);
DrawableCompat.setTint(d, Color.LTGRAY);
int left = WIDTH - HORIZONTAL_PADDING - width;
if (isArticleRTL) {
left = HORIZONTAL_PADDING;
}
int right = left + width;
d.setBounds(left, top, right, bottom);
d.draw(canvas);
}
private static void drawWordmarkFromText(@NonNull Context context, @NonNull Canvas canvas,
boolean isArticleRTL) {
final int maxWidth = WIDTH - DESCRIPTION_WIDTH - 2 * HORIZONTAL_PADDING;
final float fontSize = 20.0f;
final float scaleX = 1.06f;
TextPaint textPaint = new TextPaint();
textPaint.setAntiAlias(true);
textPaint.setColor(Color.LTGRAY);
textPaint.setTextSize(fontSize);
textPaint.setTypeface(SERIF);
textPaint.setTextScaleX(scaleX);
Spanned wikipedia = StringUtil.fromHtml(context.getString(R.string.wp_stylized));
Layout.Alignment align = L10nUtil.isDeviceRTL() ? ALIGN_OPPOSITE : ALIGN_NORMAL;
StaticLayout wordmarkLayout = buildLayout(
new TextLayoutParams(wikipedia, textPaint, maxWidth, 1.0f, align));
final int width = (int) wordmarkLayout.getLineWidth(0);
final int height = wordmarkLayout.getHeight();
final int bottom = HEIGHT - BOTTOM_PADDING;
final int top = bottom - height;
int left = WIDTH - HORIZONTAL_PADDING - width;
if (isArticleRTL) {
left = HORIZONTAL_PADDING;
}
canvas.save(); // --
canvas.translate(left, top);
wordmarkLayout.draw(canvas);
canvas.restore(); // --
}
/**
* If the title or text is too long we first reduce the font size.
* If that is not enough it gets ellipsized.
*/
private static StaticLayout optimizeTextSize(TextLayoutParams params, int maxHeight, int maxLines,
float maxFontSize, float minFontSize) {
final float threshold1 = 60.0f;
final float threshold2 = 40.0f;
final float extraStep1 = 3.0f;
final float extraStep2 = 1.0f;
boolean fits = false;
StaticLayout textLayout = null;
// Try decreasing font size first
for (float fontSize = maxFontSize; fontSize >= minFontSize; fontSize -= 1.0f) {
params.textPaint.setTextSize(fontSize);
textLayout = buildLayout(params);
if (textLayout.getHeight() <= maxHeight) {
fits = true;
break;
}
// make it go faster at the beginning...
if (fontSize > threshold1) {
fontSize -= extraStep1;
} else if (fontSize > threshold2) {
fontSize -= extraStep2;
}
}
// Then do own ellipsize: cut text off after last fitting space and add "..."
// Didn't want to cut off randomly in the middle of a line or word.
if (!fits) {
final String textStr = params.text.toString();
final int ellipsisLength = 3;
final int ellipsisStart = textLayout != null
? textLayout.getLineStart(maxLines) - ellipsisLength
: textStr.length();
final int end = textStr.lastIndexOf(' ', ellipsisStart) + 1;
if (end > 0) {
textLayout = buildLayout(
new TextLayoutParams(params, textStr.substring(0, end) + "..."));
if (textLayout.getLineCount() <= maxLines) {
fits = true;
}
}
}
// last resort: use TextUtils.ellipsize()
if (!fits) {
final float textRatio = .87f;
final float maxWidth = textRatio * maxLines * params.lineWidth;
textLayout = buildLayout(new TextLayoutParams(params,
TextUtils.ellipsize(params.text, params.textPaint, maxWidth,
TextUtils.TruncateAt.END)));
}
return textLayout;
}
private static StaticLayout buildLayout(TextLayoutParams params) {
return new StaticLayout(
params.text,
params.textPaint,
params.lineWidth,
params.align,
params.spacingMultiplier,
0.0f,
false);
}
// Borrowed from http://stackoverflow.com/questions/5226922/crop-to-fit-image-in-android
// Modified to allow for face detection adjustment, startY
@NonNull private static Bitmap scaleCropToFit(@NonNull Bitmap original, int targetWidth,
int targetHeight) {
// Need to scale the image, keeping the aspect ratio first
int width = original.getWidth();
int height = original.getHeight();
float widthScale = (float) targetWidth / (float) width;
float heightScale = (float) targetHeight / (float) height;
float scaledWidth;
float scaledHeight;
int startX = 0;
int startY = 0;
if (widthScale > heightScale) {
scaledWidth = targetWidth;
scaledHeight = height * widthScale;
startY = (int) (scaledHeight - targetHeight) / 2;
if (startY < 0) {
startY = 0;
} else if (startY + targetHeight > scaledHeight) {
startY = (int)(scaledHeight - targetHeight);
}
} else {
scaledHeight = targetHeight;
scaledWidth = width * heightScale;
}
Bitmap scaledBitmap
= Bitmap.createScaledBitmap(original, (int) scaledWidth, (int) scaledHeight, true);
Bitmap bitmap = Bitmap.createBitmap(scaledBitmap, startX, startY, targetWidth, targetHeight);
scaledBitmap.recycle();
return bitmap;
}
/**
* Parameter object for #buildLayout and #optimizeTextSize.
*/
private static class TextLayoutParams {
private final CharSequence text;
private final TextPaint textPaint;
private final int lineWidth;
private final float spacingMultiplier;
private final Layout.Alignment align;
/** Copy constructor with updated text */
TextLayoutParams(TextLayoutParams other, CharSequence text) {
this.text = text;
this.textPaint = other.textPaint;
this.lineWidth = other.lineWidth;
this.spacingMultiplier = other.spacingMultiplier;
this.align = other.align;
}
TextLayoutParams(CharSequence text, TextPaint textPaint, int lineWidth,
float spacingMultiplier, Layout.Alignment align) {
this.text = text;
this.textPaint = textPaint;
this.lineWidth = lineWidth;
this.spacingMultiplier = spacingMultiplier;
this.align = align;
}
private TextLayoutParams(CharSequence text, TextPaint textPaint, int lineWidth,
float spacingMultiplier) {
this(text, textPaint, lineWidth, spacingMultiplier, ALIGN_NORMAL);
}
}
private SnippetImage() {
}
}