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.ReaderEmbedScanner; 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.HashSet; import java.util.List; import java.util.Random; import java.util.Set; 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()); String content = mRenderBuilder.toString(); if (!(hasTiledGallery && mResourceVars.isWideDisplay)) { resizeImages(content); } resizeIframes(content); // Get the set of JS scripts to inject in our Webview to support some specific Embeds. Set<String> jsToInject = injectJSForSpecificEmbedSupport(content); final String htmlContent = formatPostContentForWebView(content, jsToInject, 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(String content) { 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(content, mPost.isPrivate); scanner.beginScan(imageListener); } /* * scan the content for iframes and make sure they're correctly sized for the device */ private void resizeIframes(String content) { ReaderHtmlUtils.HtmlScannerListener iframeListener = new ReaderHtmlUtils.HtmlScannerListener() { @Override public void onTagFound(String tag, String src) { replaceIframeTag(tag, src); } }; ReaderIframeScanner scanner = new ReaderIframeScanner(content); scanner.beginScan(iframeListener); } private Set<String> injectJSForSpecificEmbedSupport(String content) { final Set<String> jsToInject = new HashSet<>(); ReaderHtmlUtils.HtmlScannerListener embedListener = new ReaderHtmlUtils.HtmlScannerListener() { @Override public void onTagFound(String tag, String src) { jsToInject.add(src); } }; ReaderEmbedScanner scanner = new ReaderEmbedScanner(content); scanner.beginScan(embedListener); return jsToInject; } /* * 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, final Set<String> jsToInject, 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'>") .append("<style type='text/css'>") .append(" body { font-family: 'Noto Serif', 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 .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 + ")" : "") + ", li { line-height: 1.6em; font-size: 100%; }") .append(" h1, h2 { line-height: 1.2em; }") // counteract pre-defined height/width styles, expect 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(.tiled-gallery.*)" : "") + ", 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 { color: ").append(mResourceVars.greyMediumDarkStr).append("; ") .append(" padding-left: 32px; ") .append(" margin-left: 0px; ") .append(" border-left: 3px solid ").append(mResourceVars.greyExtraLightStr).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; }") // hide forms, form-related elements, legacy RSS sharing links and other ad-related content // https://github.com/Automattic/wp-calypso/blob/f51293caa87edcd4f0c117aaea8cf65d26e33520/client/lib/post-normalizer/rule-content-sanitize.js .append(" form, input, select, button textarea { display: none; }") .append(" div.feedflare { display: none; }") .append(" .sharedaddy, .jp-relatedposts, .mc4wp-form, .wpcnt, .OUTBRAIN, .adsbygoogle { display: none; }") .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"); } for (String jsUrl : jsToInject) { sbHtml.append("<script src=\"").append(jsUrl).append("\" type=\"text/javascript\" async></script>"); } 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.getText(), 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); } }