diff options
Diffstat (limited to 'WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostRenderer.java')
-rw-r--r-- | WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostRenderer.java | 580 |
1 files changed, 580 insertions, 0 deletions
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostRenderer.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostRenderer.java new file mode 100644 index 000000000..1a2552331 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostRenderer.java @@ -0,0 +1,580 @@ +package org.wordpress.android.ui.reader; + +import android.annotation.SuppressLint; +import android.net.Uri; +import android.os.Handler; + +import org.wordpress.android.R; +import org.wordpress.android.WordPress; +import org.wordpress.android.models.ReaderPost; +import org.wordpress.android.models.ReaderPostDiscoverData; +import org.wordpress.android.ui.reader.utils.ImageSizeMap; +import org.wordpress.android.ui.reader.utils.ImageSizeMap.ImageSize; +import org.wordpress.android.ui.reader.utils.ReaderHtmlUtils; +import org.wordpress.android.ui.reader.utils.ReaderIframeScanner; +import org.wordpress.android.ui.reader.utils.ReaderImageScanner; +import org.wordpress.android.ui.reader.utils.ReaderUtils; +import org.wordpress.android.ui.reader.views.ReaderWebView; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.DisplayUtils; +import org.wordpress.android.util.PhotonUtils; +import org.wordpress.android.util.StringUtils; + +import java.lang.ref.WeakReference; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.regex.Pattern; + +/** + * generates and displays the HTML for post detail content - main purpose is to assign the + * height/width attributes on image tags to (1) avoid the webView resizing as images are + * loaded, and (2) avoid requesting images at a size larger than the display + * + * important to note that displayed images rely on dp rather than px sizes due to the + * fact that WebView "converts CSS pixel values to density-independent pixel values" + * http://developer.android.com/guide/webapps/targeting.html + */ +class ReaderPostRenderer { + + private final ReaderResourceVars mResourceVars; + private final ReaderPost mPost; + private final int mMinFullSizeWidthDp; + private final int mMinMidSizeWidthDp; + private final WeakReference<ReaderWebView> mWeakWebView; + + private StringBuilder mRenderBuilder; + private String mRenderedHtml; + private ImageSizeMap mAttachmentSizes; + + @SuppressLint("SetJavaScriptEnabled") + ReaderPostRenderer(ReaderWebView webView, ReaderPost post) { + if (webView == null) { + throw new IllegalArgumentException("ReaderPostRenderer requires a webView"); + } + if (post == null) { + throw new IllegalArgumentException("ReaderPostRenderer requires a post"); + } + + mPost = post; + mWeakWebView = new WeakReference<>(webView); + mResourceVars = new ReaderResourceVars(webView.getContext()); + + mMinFullSizeWidthDp = pxToDp(mResourceVars.fullSizeImageWidthPx / 3); + mMinMidSizeWidthDp = mMinFullSizeWidthDp / 2; + + // enable JavaScript in the webView, otherwise videos and other embedded content won't + // work - note that the content is scrubbed on the backend so this is considered safe + webView.getSettings().setJavaScriptEnabled(true); + } + + void beginRender() { + final Handler handler = new Handler(); + mRenderBuilder = new StringBuilder(getPostContent()); + + new Thread() { + @Override + public void run() { + final boolean hasTiledGallery = hasTiledGallery(mRenderBuilder.toString()); + + if (!(hasTiledGallery && mResourceVars.isWideDisplay)) { + resizeImages(); + } + + resizeIframes(); + + final String htmlContent = formatPostContentForWebView(mRenderBuilder.toString(), hasTiledGallery, + mResourceVars.isWideDisplay); + mRenderBuilder = null; + handler.post(new Runnable() { + @Override + public void run() { + renderHtmlContent(htmlContent); + } + }); + } + }.start(); + } + + public static boolean hasTiledGallery(String text) { + // determine whether a tiled-gallery exists in the content + return Pattern.compile("tiled-gallery[\\s\"']").matcher(text).find(); + } + + /* + * scan the content for images and make sure they're correctly sized for the device + */ + private void resizeImages() { + ReaderHtmlUtils.HtmlScannerListener imageListener = new ReaderHtmlUtils.HtmlScannerListener() { + @Override + public void onTagFound(String imageTag, String imageUrl) { + if (!imageUrl.contains("wpcom-smileys")) { + replaceImageTag(imageTag, imageUrl); + } + } + }; + ReaderImageScanner scanner = new ReaderImageScanner(mRenderBuilder.toString(), mPost.isPrivate); + scanner.beginScan(imageListener); + } + + /* + * scan the content for iframes and make sure they're correctly sized for the device + */ + private void resizeIframes() { + ReaderHtmlUtils.HtmlScannerListener iframeListener = new ReaderHtmlUtils.HtmlScannerListener() { + @Override + public void onTagFound(String tag, String src) { + replaceIframeTag(tag, src); + } + }; + ReaderIframeScanner scanner = new ReaderIframeScanner(mRenderBuilder.toString()); + scanner.beginScan(iframeListener); + } + + /* + * called once the content is ready to be rendered in the webView + */ + private void renderHtmlContent(final String htmlContent) { + mRenderedHtml = htmlContent; + + // make sure webView is still valid (containing fragment may have been detached) + ReaderWebView webView = mWeakWebView.get(); + if (webView == null || webView.getContext() == null || webView.isDestroyed()) { + AppLog.w(AppLog.T.READER, "reader renderer > webView invalid"); + return; + } + + // IMPORTANT: use loadDataWithBaseURL() since loadData() may fail + // https://code.google.com/p/android/issues/detail?id=4401 + // also important to use null as the baseUrl since onPageFinished + // doesn't appear to fire when it's set to an actual url + webView.loadDataWithBaseURL(null, htmlContent, "text/html", "UTF-8", null); + } + + /* + * called when image scanner finds an image, tries to replace the image tag with one that + * has height & width attributes set correctly for the current display, if that fails + * replaces it with one that has our 'size-none' class + */ + private void replaceImageTag(final String imageTag, final String imageUrl) { + ImageSize origSize = getImageSize(imageTag, imageUrl); + boolean hasWidth = (origSize != null && origSize.width > 0); + boolean isFullSize = hasWidth && (origSize.width >= mMinFullSizeWidthDp); + boolean isMidSize = hasWidth + && (origSize.width >= mMinMidSizeWidthDp) + && (origSize.width < mMinFullSizeWidthDp); + + final String newImageTag; + if (isFullSize) { + newImageTag = makeFullSizeImageTag(imageUrl, origSize.width, origSize.height); + } else if (isMidSize) { + newImageTag = makeImageTag(imageUrl, origSize.width, origSize.height, "size-medium"); + } else if (hasWidth) { + newImageTag = makeImageTag(imageUrl, origSize.width, origSize.height, "size-none"); + } else { + newImageTag = "<img class='size-none' src='" + imageUrl + "' />"; + } + + int start = mRenderBuilder.indexOf(imageTag); + if (start == -1) { + AppLog.w(AppLog.T.READER, "reader renderer > image not found in builder"); + return; + } + + mRenderBuilder.replace(start, start + imageTag.length(), newImageTag); + } + + private String makeImageTag(final String imageUrl, int width, int height, final String imageClass) { + String newImageUrl = ReaderUtils.getResizedImageUrl(imageUrl, width, height, mPost.isPrivate); + if (height > 0) { + return "<img class='" + imageClass + "'" + + " src='" + newImageUrl + "'" + + " width='" + pxToDp(width) + "'" + + " height='" + pxToDp(height) + "' />"; + } else { + return "<img class='" + imageClass + "'" + + "src='" + newImageUrl + "'" + + " width='" + pxToDp(width) + "' />"; + } + } + + private String makeFullSizeImageTag(final String imageUrl, int width, int height) { + int newWidth; + int newHeight; + if (width > 0 && height > 0) { + if (height > width) { + //noinspection SuspiciousNameCombination + newHeight = mResourceVars.fullSizeImageWidthPx; + float ratio = ((float) width / (float) height); + newWidth = (int) (newHeight * ratio); + } else { + float ratio = ((float) height / (float) width); + newWidth = mResourceVars.fullSizeImageWidthPx; + newHeight = (int) (newWidth * ratio); + } + } else { + newWidth = mResourceVars.fullSizeImageWidthPx; + newHeight = 0; + } + + return makeImageTag(imageUrl, newWidth, newHeight, "size-full"); + } + + /* + * returns true if the post has a featured image and there are no images in the + * post's content - when this is the case, the featured image is inserted at + * the top of the content + */ + private boolean shouldAddFeaturedImage() { + return mPost.hasFeaturedImage() + && !mPost.getText().contains("<img") + && !PhotonUtils.isMshotsUrl(mPost.getFeaturedImage()); + } + + /* + * returns the basic content of the post tweaked for use here + */ + private String getPostContent() { + // some content (such as Vimeo embeds) don't have "http:" before links + String content = mPost.getText().replace("src=\"//", "src=\"http://"); + + // add the featured image (if any) + if (shouldAddFeaturedImage()) { + AppLog.d(AppLog.T.READER, "reader renderer > added featured image"); + content = getFeaturedImageHtml() + content; + } + + // if this is a Discover post, add a link which shows the blog preview + if (mPost.isDiscoverPost()) { + ReaderPostDiscoverData discoverData = mPost.getDiscoverData(); + if (discoverData != null && discoverData.getBlogId() != 0 && discoverData.hasBlogName()) { + String label = String.format( + WordPress.getContext().getString(R.string.reader_discover_visit_blog), discoverData.getBlogName()); + String url = ReaderUtils.makeBlogPreviewUrl(discoverData.getBlogId()); + + String htmlDiscover = "<div id='discover'>" + + "<a href='" + url + "'>" + label + "</a>" + + "</div>"; + content += htmlDiscover; + } + } + + return content; + } + + /* + * returns the HTML that was last rendered, will be null prior to rendering + */ + String getRenderedHtml() { + return mRenderedHtml; + } + + /* + * returns the HTML to use when inserting a featured image into the rendered content + */ + private String getFeaturedImageHtml() { + String imageUrl = ReaderUtils.getResizedImageUrl( + mPost.getFeaturedImage(), + mResourceVars.fullSizeImageWidthPx, + mResourceVars.featuredImageHeightPx, + mPost.isPrivate); + + return "<img class='size-full' src='" + imageUrl + "'/>"; + } + + /* + * replace the passed iframe tag with one that's correctly sized for the device + */ + private void replaceIframeTag(final String tag, final String src) { + int width = ReaderHtmlUtils.getWidthAttrValue(tag); + int height = ReaderHtmlUtils.getHeightAttrValue(tag); + + int newHeight; + int newWidth; + if (width > 0 && height > 0) { + float ratio = ((float) height / (float) width); + newWidth = mResourceVars.videoWidthPx; + newHeight = (int) (newWidth * ratio); + } else { + newWidth = mResourceVars.videoWidthPx; + newHeight = mResourceVars.videoHeightPx; + } + + String newTag = "<iframe src='" + src + "'" + + " frameborder='0' allowfullscreen='true' allowtransparency='true'" + + " width='" + pxToDp(newWidth) + "'" + + " height='" + pxToDp(newHeight) + "' />"; + + int start = mRenderBuilder.indexOf(tag); + if (start == -1) { + AppLog.w(AppLog.T.READER, "reader renderer > iframe not found in builder"); + return; + } + + mRenderBuilder.replace(start, start + tag.length(), newTag); + } + + /* + * returns the full content, including CSS, that will be shown in the WebView for this post + */ + private String formatPostContentForWebView(final String content, boolean hasTiledGallery, boolean isWideDisplay) { + final boolean renderAsTiledGallery = hasTiledGallery && isWideDisplay; + + // unique CSS class assigned to the gallery elements for easy selection + final String galleryOnlyClass = "gallery-only-class" + new Random().nextInt(1000); + + @SuppressWarnings("StringBufferReplaceableByString") + StringBuilder sbHtml = new StringBuilder("<!DOCTYPE html><html><head><meta charset='UTF-8' />"); + + // title isn't necessary, but it's invalid html5 without one + sbHtml.append("<title>Reader Post</title>") + + // https://developers.google.com/chrome/mobile/docs/webview/pixelperfect + .append("<meta name='viewport' content='width=device-width, initial-scale=1'>") + + // use Merriweather font assets + .append("<link href='file:///android_asset/merriweather.css' rel='stylesheet' type='text/css'>") + + .append("<style type='text/css'>") + .append(" body { font-family: Merriweather, serif; font-weight: 400; margin: 0px; padding: 0px;}") + .append(" body, p, div { max-width: 100% !important; word-wrap: break-word; }") + + // set line-height, font-size but not for gallery divs when rendering as tiled gallery as those will be + // handled with the .tiled-gallery rules bellow. + .append(" p, div" + (renderAsTiledGallery ? ":not(." + galleryOnlyClass + ")" : "") + + ", li { line-height: 1.6em; font-size: 100%; }") + + .append(" h1, h2 { line-height: 1.2em; }") + + // counteract pre-defined height/width styles, except for the tiled-gallery divs when rendering as tiled gallery + // as those will be handled with the .tiled-gallery rules bellow. + .append(" p, div" + (renderAsTiledGallery ? ":not(." + galleryOnlyClass + ")" : "") + + ", dl, table { width: auto !important; height: auto !important; }") + + // make sure long strings don't force the user to scroll horizontally + .append(" body, p, div, a { word-wrap: break-word; }") + + // use a consistent top/bottom margin for paragraphs, with no top margin for the first one + .append(" p { margin-top: ").append(mResourceVars.marginMediumPx).append("px;") + .append(" margin-bottom: ").append(mResourceVars.marginMediumPx).append("px; }") + .append(" p:first-child { margin-top: 0px; }") + + // add background color and padding to pre blocks, and add overflow scrolling + // so user can scroll the block if it's wider than the display + .append(" pre { overflow-x: scroll;") + .append(" background-color: ").append(mResourceVars.greyExtraLightStr).append("; ") + .append(" padding: ").append(mResourceVars.marginMediumPx).append("px; }") + + // add a left border to blockquotes + .append(" blockquote { margin-left: ").append(mResourceVars.marginMediumPx).append("px; ") + .append(" padding-left: ").append(mResourceVars.marginMediumPx).append("px; ") + .append(" border-left: 3px solid ").append(mResourceVars.greyLightStr).append("; }") + + // show links in the same color they are elsewhere in the app + .append(" a { text-decoration: none; color: ").append(mResourceVars.linkColorStr).append("; }") + + // make sure images aren't wider than the display, strictly enforced for images without size + .append(" img { max-width: 100%; width: auto; height: auto; }") + .append(" img.size-none { max-width: 100% !important; height: auto !important; }") + + // center large/medium images, provide a small bottom margin, and add a background color + // so the user sees something while they're loading + .append(" img.size-full, img.size-large, img.size-medium {") + .append(" display: block; margin-left: auto; margin-right: auto;") + .append(" background-color: ").append(mResourceVars.greyExtraLightStr).append(";") + .append(" margin-bottom: ").append(mResourceVars.marginMediumPx).append("px; }"); + + if (isWideDisplay) { + sbHtml + .append(".alignleft {") + .append(" max-width: 100%;") + .append(" float: left;") + .append(" margin-top: 12px;") + .append(" margin-bottom: 12px;") + .append(" margin-right: 32px;}") + .append(".alignright {") + .append(" max-width: 100%;") + .append(" float: right;") + .append(" margin-top: 12px;") + .append(" margin-bottom: 12px;") + .append(" margin-left: 32px;}"); + } + + if (renderAsTiledGallery) { + // tiled-gallery related styles + sbHtml + .append(".tiled-gallery {") + .append(" clear:both;") + .append(" overflow:hidden;}") + .append(".tiled-gallery img {") + .append(" margin:2px !important;}") + .append(".tiled-gallery .gallery-group {") + .append(" float:left;") + .append(" position:relative;}") + .append(".tiled-gallery .tiled-gallery-item {") + .append(" float:left;") + .append(" margin:0;") + .append(" position:relative;") + .append(" width:inherit;}") + .append(".tiled-gallery .gallery-row {") + .append(" position: relative;") + .append(" left: 50%;") + .append(" -webkit-transform: translateX(-50%);") + .append(" -moz-transform: translateX(-50%);") + .append(" transform: translateX(-50%);") + .append(" overflow:hidden;}") + .append(".tiled-gallery .tiled-gallery-item a {") + .append(" background:transparent;") + .append(" border:none;") + .append(" color:inherit;") + .append(" margin:0;") + .append(" padding:0;") + .append(" text-decoration:none;") + .append(" width:auto;}") + .append(".tiled-gallery .tiled-gallery-item img,") + .append(".tiled-gallery .tiled-gallery-item img:hover {") + .append(" background:none;") + .append(" border:none;") + .append(" box-shadow:none;") + .append(" max-width:100%;") + .append(" padding:0;") + .append(" vertical-align:middle;}") + .append(".tiled-gallery-caption {") + .append(" background:#eee;") + .append(" background:rgba( 255,255,255,0.8 );") + .append(" color:#333;") + .append(" font-size:13px;") + .append(" font-weight:400;") + .append(" overflow:hidden;") + .append(" padding:10px 0;") + .append(" position:absolute;") + .append(" bottom:0;") + .append(" text-indent:10px;") + .append(" text-overflow:ellipsis;") + .append(" width:100%;") + .append(" white-space:nowrap;}") + .append(".tiled-gallery .tiled-gallery-item-small .tiled-gallery-caption {") + .append(" font-size:11px;}") + .append(".widget-gallery .tiled-gallery-unresized {") + .append(" visibility:hidden;") + .append(" height:0px;") + .append(" overflow:hidden;}") + .append(".tiled-gallery .tiled-gallery-item img.grayscale {") + .append(" position:absolute;") + .append(" left:0;") + .append(" top:0;}") + .append(".tiled-gallery .tiled-gallery-item img.grayscale:hover {") + .append(" opacity:0;}") + .append(".tiled-gallery.type-circle .tiled-gallery-item img {") + .append(" border-radius:50% !important;}") + .append(".tiled-gallery.type-circle .tiled-gallery-caption {") + .append(" display:none;") + .append(" opacity:0;}"); + } + + // see http://codex.wordpress.org/CSS#WordPress_Generated_Classes + sbHtml + .append(" .wp-caption img { margin-top: 0px; margin-bottom: 0px; }") + .append(" .wp-caption .wp-caption-text {") + .append(" font-size: smaller; line-height: 1.2em; margin: 0px;") + .append(" text-align: center;") + .append(" padding: ").append(mResourceVars.marginMediumPx).append("px; ") + .append(" color: ").append(mResourceVars.greyMediumDarkStr).append("; }") + + // attribution for Discover posts + .append(" div#discover { ") + .append(" margin-top: ").append(mResourceVars.marginMediumPx).append("px;") + .append(" font-family: sans-serif;") + .append(" }") + + // horizontally center iframes + .append(" iframe { display: block; margin: 0 auto; }") + + // make sure html5 videos fit the browser width and use 16:9 ratio (YouTube standard) + .append(" video {") + .append(" width: ").append(pxToDp(mResourceVars.videoWidthPx)).append("px !important;") + .append(" height: ").append(pxToDp(mResourceVars.videoHeightPx)).append("px !important; }") + + .append("</style>"); + + // add a custom CSS class to (any) tiled gallery elements to make them easier selectable for various rules + final List<String> classAmendRegexes = Arrays.asList( + "(tiled-gallery)([\\s\"\'])", + "(gallery-row)([\\s\"'])", + "(gallery-group)([\\s\"'])", + "(tiled-gallery-item)([\\s\"'])"); + String contentCustomised = content; + for (String classToAmend : classAmendRegexes) { + contentCustomised = contentCustomised.replaceAll(classToAmend, "$1 " + galleryOnlyClass + "$2"); + } + + sbHtml.append("</head><body>") + .append(contentCustomised) + .append("</body></html>"); + + return sbHtml.toString(); + } + + private ImageSize getImageSize(final String imageTag, final String imageUrl) { + ImageSize size = getImageSizeFromAttachments(imageUrl); + if (size == null && imageTag.contains("data-orig-size=")) { + size = getImageOriginalSizeFromAttributes(imageTag); + } + if (size == null && imageUrl.contains("?")) { + size = getImageSizeFromQueryParams(imageUrl); + } + if (size == null && imageTag.contains("width=")) { + size = getImageSizeFromAttributes(imageTag); + } + return size; + } + + private ImageSize getImageSizeFromAttachments(final String imageUrl) { + if (mAttachmentSizes == null) { + mAttachmentSizes = new ImageSizeMap(mPost.getAttachmentsJson()); + } + return mAttachmentSizes.getImageSize(imageUrl); + } + + private ImageSize getImageSizeFromQueryParams(final String imageUrl) { + if (imageUrl.contains("w=")) { + Uri uri = Uri.parse(imageUrl.replace("&", "&")); + return new ImageSize( + StringUtils.stringToInt(uri.getQueryParameter("w")), + StringUtils.stringToInt(uri.getQueryParameter("h"))); + } else if (imageUrl.contains("resize=")) { + Uri uri = Uri.parse(imageUrl.replace("&", "&")); + String param = uri.getQueryParameter("resize"); + if (param != null) { + String[] sizes = param.split(","); + if (sizes.length == 2) { + return new ImageSize( + StringUtils.stringToInt(sizes[0]), + StringUtils.stringToInt(sizes[1])); + } + } + } + + return null; + } + + private ImageSize getImageOriginalSizeFromAttributes(final String imageTag) { + return new ImageSize( + ReaderHtmlUtils.getOriginalWidthAttrValue(imageTag), + ReaderHtmlUtils.getOriginalHeightAttrValue(imageTag)); + } + + private ImageSize getImageSizeFromAttributes(final String imageTag) { + return new ImageSize( + ReaderHtmlUtils.getWidthAttrValue(imageTag), + ReaderHtmlUtils.getHeightAttrValue(imageTag)); + } + + private int pxToDp(int px) { + if (px == 0) { + return 0; + } + return DisplayUtils.pxToDp(WordPress.getContext(), px); + } + +} |