| /* |
| * Copyright 2014 Google Inc. |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| // Author: jmarantz@google.com (Joshua Marantz) |
| // Author: sligocki@google.com (Shawn Ligocki) |
| |
| #include "net/instaweb/rewriter/public/responsive_image_filter.h" |
| |
| #include <cstddef> // for size_t |
| #include <memory> |
| #include <utility> // for pair |
| |
| #include "base/logging.h" |
| #include "net/instaweb/rewriter/cached_result.pb.h" |
| #include "net/instaweb/rewriter/public/rewrite_driver.h" |
| #include "net/instaweb/rewriter/public/rewrite_options.h" |
| #include "net/instaweb/rewriter/public/server_context.h" |
| #include "net/instaweb/rewriter/public/static_asset_manager.h" |
| #include "pagespeed/kernel/base/string.h" |
| #include "pagespeed/kernel/base/string_util.h" |
| #include "pagespeed/kernel/html/html_element.h" |
| #include "pagespeed/kernel/html/html_name.h" |
| #include "pagespeed/kernel/http/data_url.h" |
| #include "pagespeed/kernel/http/google_url.h" |
| |
| namespace net_instaweb { |
| |
| const char ResponsiveImageFirstFilter::kOriginalImage[] = "original"; |
| const char ResponsiveImageFirstFilter::kNonInlinableVirtualImage[] = |
| "non-inlinable-virtual"; |
| const char ResponsiveImageFirstFilter::kInlinableVirtualImage[] = |
| "inlinable-virtual"; |
| const char ResponsiveImageFirstFilter::kFullsizedVirtualImage[] = |
| "fullsized-virtual"; |
| |
| ResponsiveImageFirstFilter::ResponsiveImageFirstFilter(RewriteDriver* driver) |
| : CommonFilter(driver), |
| densities_(driver->options()->responsive_image_densities()) { |
| CHECK(!densities_.empty()); |
| } |
| |
| ResponsiveImageFirstFilter::~ResponsiveImageFirstFilter() { |
| } |
| |
| void ResponsiveImageFirstFilter::StartDocumentImpl() { |
| candidate_map_.clear(); |
| } |
| |
| void ResponsiveImageFirstFilter::EndElementImpl(HtmlElement* element) { |
| if (element->keyword() != HtmlName::kImg) { |
| return; |
| } |
| |
| if (element->FindAttribute(HtmlName::kDataPagespeedNoTransform) != NULL || |
| element->FindAttribute(HtmlName::kPagespeedNoTransform) != NULL) { |
| driver()->InsertDebugComment( |
| "ResponsiveImageFilter: Not adding srcset because of " |
| "data-pagespeed-no-transform attribute.", element); |
| } else if (element->FindAttribute(HtmlName::kSrcset) != NULL) { |
| driver()->InsertDebugComment( |
| "ResponsiveImageFilter: Not adding srcset because image already " |
| "has one.", element); |
| } else if (element->FindAttribute(HtmlName::kDataPagespeedResponsiveTemp) == |
| NULL) { |
| // On first run of this filter, split <img> element into multiple |
| // elements. |
| AddHiResImages(element); |
| } |
| } |
| |
| // Adds dummy images for 1.5x and 2x resolutions. Note: this converts: |
| // <img src=foo.jpg width=w height=h> |
| // into: |
| // <img src=foo.jpg width=1.5w height=1.5h pagespeed_responsive_temp> |
| // <img src=foo.jpg width=2w height=2h pagespeed_responsive_temp> |
| // <img src=foo.jpg width=w height=h> |
| // The order of these images doesn't really matter, but adding them before |
| // this image avoids some extra processing of the added dummy images by |
| // ResponsiveImageFirstFilter. |
| void ResponsiveImageFirstFilter::AddHiResImages(HtmlElement* element) { |
| const HtmlElement::Attribute* src_attr = |
| element->FindAttribute(HtmlName::kSrc); |
| // TODO(sligocki): width and height attributes can lie. Perhaps we should |
| // look at rendered image dimensions (via beaconing back from clients). |
| const char* width_str = element->AttributeValue(HtmlName::kWidth); |
| const char* height_str = element->AttributeValue(HtmlName::kHeight); |
| if ((src_attr == NULL) || (width_str == NULL) || (height_str == NULL)) { |
| driver()->InsertDebugComment( |
| "ResponsiveImageFilter: Not adding srcset because image does not " |
| "have dimensions (or a src URL).", element); |
| return; |
| } |
| |
| int orig_width, orig_height; |
| if (StringToInt(width_str, &orig_width) && |
| StringToInt(height_str, &orig_height)) { |
| if (orig_width <= 1 || orig_height <= 1) { |
| driver()->InsertDebugComment( |
| "ResponsiveImageFilter: Not adding srcset to tracking pixel.", |
| element); |
| return; |
| } |
| |
| // TODO(sligocki): Possibly use lower quality settings for 1.5x and 2x |
| // because standard quality-85 are overkill for high density displays. |
| // However, we might want high quality for zoom. |
| ResponsiveVirtualImages virtual_images; |
| virtual_images.width = orig_width; |
| virtual_images.height = orig_height; |
| |
| for (size_t i = 0, n = densities_.size(); i < n; ++i) { |
| virtual_images.non_inlinable_candidates.push_back( |
| AddHiResVersion(element, *src_attr, orig_width, orig_height, |
| kNonInlinableVirtualImage, densities_[i])); |
| } |
| |
| // Highest quality version. |
| virtual_images.inlinable_candidate = |
| AddHiResVersion(element, *src_attr, orig_width, orig_height, |
| kInlinableVirtualImage, |
| densities_[densities_.size() - 1]); |
| |
| virtual_images.fullsized_candidate = |
| AddHiResVersion(element, *src_attr, orig_width, orig_height, |
| kFullsizedVirtualImage, -1); |
| |
| candidate_map_[element] = virtual_images; |
| |
| // Mark this element as responsive as well, so that ImageRewriteFilter will |
| // add actual final dimensions to the tag. |
| driver()->AddAttribute(element, HtmlName::kDataPagespeedResponsiveTemp, |
| kOriginalImage); |
| } |
| } |
| |
| ResponsiveImageCandidate ResponsiveImageFirstFilter::AddHiResVersion( |
| HtmlElement* img, const HtmlElement::Attribute& src_attr, |
| int orig_width, int orig_height, StringPiece responsive_attribute_value, |
| double resolution) { |
| HtmlElement* new_img = driver()->NewElement(img->parent(), HtmlName::kImg); |
| new_img->AddAttribute(src_attr); |
| driver()->AddAttribute(new_img, HtmlName::kDataPagespeedResponsiveTemp, |
| responsive_attribute_value); |
| if (resolution > 0) { |
| // Note: We truncate width and height to integers here. |
| driver()->AddAttribute(new_img, HtmlName::kWidth, |
| IntegerToString(orig_width * resolution)); |
| driver()->AddAttribute(new_img, HtmlName::kHeight, |
| IntegerToString(orig_height * resolution)); |
| } |
| driver()->InsertNodeBeforeNode(img, new_img); |
| ResponsiveImageCandidate candidate(new_img, resolution); |
| return candidate; |
| } |
| |
| ResponsiveImageSecondFilter::ResponsiveImageSecondFilter( |
| RewriteDriver* driver, const ResponsiveImageFirstFilter* first_filter) |
| : CommonFilter(driver), |
| responsive_js_url_( |
| driver->server_context()->static_asset_manager()->GetAssetUrl( |
| StaticAssetEnum::RESPONSIVE_JS, driver->options())), |
| first_filter_(first_filter), |
| zoom_filter_enabled_(driver->options()->Enabled( |
| RewriteOptions::kResponsiveImagesZoom)), |
| srcsets_added_(false) { |
| } |
| |
| ResponsiveImageSecondFilter::~ResponsiveImageSecondFilter() { |
| } |
| |
| void ResponsiveImageSecondFilter::StartDocumentImpl() { |
| srcsets_added_ = false; |
| } |
| |
| void ResponsiveImageSecondFilter::EndElementImpl(HtmlElement* element) { |
| if (element->keyword() != HtmlName::kImg) { |
| return; |
| } |
| |
| ResponsiveImageCandidateMap::const_iterator p = |
| first_filter_->candidate_map_.find(element); |
| if (p != first_filter_->candidate_map_.end()) { |
| // On second run of the filter, combine the elements back together. |
| const ResponsiveVirtualImages& virtual_images = p->second; |
| CombineHiResImages(element, virtual_images); |
| Cleanup(element, virtual_images); |
| } |
| } |
| |
| namespace { |
| |
| // Get actual dimensions. These are inserted by ImageRewriteFilter as |
| // attributes on all images involved in the responsive flow. |
| ImageDim ActualDims(const HtmlElement* element) { |
| ImageDim dims; |
| |
| int height; |
| const char* height_str = element->AttributeValue(HtmlName::kDataActualHeight); |
| if (height_str != NULL && StringToInt(height_str, &height)) { |
| dims.set_height(height); |
| } |
| |
| int width; |
| const char* width_str = element->AttributeValue(HtmlName::kDataActualWidth); |
| if (width_str != NULL && StringToInt(width_str, &width)) { |
| dims.set_width(width); |
| } |
| |
| return dims; |
| } |
| |
| GoogleString ResolutionToString(double resolution) { |
| // Max 4 digits of precission. |
| return StringPrintf("%.4g", resolution); |
| } |
| |
| } // namespace |
| |
| // Combines information from dummy 1.5x and 2x images into the 1x srcset. |
| void ResponsiveImageSecondFilter::CombineHiResImages( |
| HtmlElement* orig_element, |
| const ResponsiveVirtualImages& virtual_images) { |
| // If the highest resolution image was inlinable, use that as the only |
| // version of the image (no srcset). |
| const char* inlinable_src = |
| virtual_images.inlinable_candidate.element->AttributeValue( |
| HtmlName::kSrc); |
| if (IsDataUrl(inlinable_src)) { |
| // Note: This throws away any Local Storage attributes associated with this |
| // inlined image. Maybe we should copy those over as well? |
| orig_element->DeleteAttribute(HtmlName::kSrc); |
| driver()->AddAttribute(orig_element, HtmlName::kSrc, inlinable_src); |
| return; |
| } |
| |
| ResponsiveImageCandidateVector candidates = |
| virtual_images.non_inlinable_candidates; |
| |
| // Find out what resolution fullsize image is and add it to candidates list. |
| ResponsiveImageCandidate fullsized = virtual_images.fullsized_candidate; |
| ImageDim full_dims = ActualDims(fullsized.element); |
| if (full_dims.width() > 0) { |
| fullsized.resolution = |
| static_cast<double>(full_dims.width()) / virtual_images.width; |
| candidates.push_back(fullsized); |
| } |
| |
| const char* x1_src = orig_element->AttributeValue(HtmlName::kSrc); |
| |
| if (x1_src == NULL) { |
| // Should not happen. We explicitly checked that <img> had a src= attribute |
| // in ResponsiveImageFirstFilter::AddHiResImages(). |
| LOG(DFATAL) << "Original responsive image has no URL."; |
| driver()->InsertDebugComment( |
| "ResponsiveImageFilter: Not adding srcset because original image has " |
| "no src URL.", orig_element); |
| return; |
| } else if (IsDataUrl(x1_src)) { |
| // Should not happen. ImageRewriteFilter should never inline the original |
| // image. Instead, if the image is small enough it will be inlined via the |
| // inlinable virtual image. |
| driver()->InsertDebugComment( |
| "ResponsiveImageFilter: Not adding srcset because original image was " |
| "inlined.", orig_element); |
| return; |
| } |
| |
| GoogleString srcset_value; |
| // Keep track of last candidate's URL. If next candidate has same URL, |
| // don't include it in the srcset. |
| GoogleString last_src = x1_src; |
| // Keep track of actual final dimensions of last candidate. If next candidate |
| // has same actual dimensions, we don't include it in the srcset. |
| ImageDim last_dims = ActualDims(orig_element); |
| bool added_hi_res = false; |
| |
| for (int i = 0, n = candidates.size(); i < n; ++i) { |
| const char* src = candidates[i].element->AttributeValue(HtmlName::kSrc); |
| |
| if (src == NULL) { |
| // Should not happen. We explicitly created a src= attribute in |
| // ResponsiveImageFirstFilter::AddHiResVersion(). |
| LOG(DFATAL) << "Virtual responsive image has no URL."; |
| driver()->InsertDebugComment( |
| "ResponsiveImageFilter: Not adding srcset because virtual image has " |
| "no src URL.", orig_element); |
| return; |
| } else if (IsDataUrl(src)) { |
| // Should not happen. ImageRewriteFilter should never inline these |
| // non-inlinable virtual images. |
| LOG(DFATAL) << "Non-inlinable image was inlined."; |
| driver()->InsertDebugComment( |
| "ResponsiveImageFilter: Not adding srcset because virtual image was " |
| "unexpectedly inlined.", orig_element); |
| return; |
| } |
| |
| ImageDim dims = ActualDims(candidates[i].element); |
| if (src == last_src) { |
| if (driver()->DebugMode()) { |
| driver()->InsertDebugComment(StrCat( |
| "ResponsiveImageFilter: Not adding ", |
| ResolutionToString(candidates[i].resolution), "x candidate to " |
| "srcset because it is the same as previous candidate."), |
| orig_element); |
| } |
| // TODO(sligocki): Remove last candidate if dimensions are too close to |
| // this candidate. Ex: if 1.5x is 99x99 and 2x is 100x100, obviously we |
| // should remove 1.5x version. |
| } else if (dims.height() == last_dims.height() && |
| dims.width() == last_dims.width()) { |
| if (driver()->DebugMode()) { |
| driver()->InsertDebugComment(StrCat( |
| "ResponsiveImageFilter: Not adding ", |
| ResolutionToString(candidates[i].resolution), "x candidate to " |
| "srcset because native image was not high enough resolution."), |
| orig_element); |
| } |
| } else { |
| if (added_hi_res) { |
| StrAppend(&srcset_value, ","); |
| } |
| // Note: Escaping and parsing rules for srcsets are very strange. |
| // Specifically, URLs in srcsets are not allowed to start nor end |
| // with a comma. Commas are allowed in the middle of a URL and do not |
| // need to be escaped. In fact, they are reserved chars in the URL spec |
| // (rfc 3986 2.2) and so escaping them as %2C would potentially change |
| // the meaning of the URL. |
| // See: http://www.w3.org/html/wg/drafts/html/master/semantics.html#attr-img-srcset |
| // |
| // Note: PageSpeed resized images will never begin nor end with a comma. |
| StringPiece src_sp(src); |
| if (src_sp.ends_with(",") || src_sp.starts_with(",")) { |
| driver()->InsertDebugComment(StrCat( |
| "ResponsiveImageFilter: Not adding srcset because one of the " |
| "candidate URLs starts or ends with a comma: ", src_sp), |
| orig_element); |
| return; |
| } |
| // However it appears that all spaces do need to be percent escaped. |
| // Otherwise srcset parsing would be ambiguous. |
| GoogleString src_escaped = GoogleUrl::Sanitize(src); |
| |
| GoogleString resolution_string = |
| ResolutionToString(candidates[i].resolution); |
| StrAppend(&srcset_value, src_escaped, " ", resolution_string, "x"); |
| |
| last_src = src; |
| last_dims = dims; |
| added_hi_res = true; |
| } |
| } |
| |
| if (added_hi_res) { |
| driver()->AddAttribute(orig_element, HtmlName::kSrcset, srcset_value); |
| srcsets_added_ = true; |
| } |
| } |
| |
| namespace { |
| |
| // Helper function which never returns NULL (and is thus safe to use directly |
| // in printf, etc.). |
| const char* AttributeValueOrEmpty(const HtmlElement* element, |
| const HtmlName::Keyword attr_name) { |
| const char* ret = element->AttributeValue(attr_name); |
| if (ret == NULL) { |
| return ""; |
| } else { |
| return ret; |
| } |
| } |
| |
| } // namespace |
| |
| void ResponsiveImageSecondFilter::InsertPlaceholderDebugComment( |
| const ResponsiveImageCandidate& candidate, const char* qualifier) { |
| if (driver()->DebugMode()) { |
| GoogleString resolution_str; |
| if (candidate.resolution > 0) { |
| resolution_str = |
| StrCat(" ", ResolutionToString(candidate.resolution), "x"); |
| } |
| driver()->InsertDebugComment(StrCat( |
| "ResponsiveImageFilter: Any debug messages after this refer to the " |
| "virtual", qualifier, resolution_str, " image with " |
| "src=", AttributeValueOrEmpty(candidate.element, HtmlName::kSrc), |
| " width=", AttributeValueOrEmpty(candidate.element, HtmlName::kWidth), |
| " height=", AttributeValueOrEmpty(candidate.element, |
| HtmlName::kHeight)), |
| candidate.element); |
| } |
| } |
| |
| void ResponsiveImageSecondFilter::Cleanup( |
| HtmlElement* orig_element, |
| const ResponsiveVirtualImages& virtual_images) { |
| for (int i = 0, n = virtual_images.non_inlinable_candidates.size(); |
| i < n; ++i) { |
| InsertPlaceholderDebugComment(virtual_images.non_inlinable_candidates[i], |
| ""); |
| driver()->DeleteNode(virtual_images.non_inlinable_candidates[i].element); |
| } |
| |
| InsertPlaceholderDebugComment(virtual_images.inlinable_candidate, |
| " inlinable"); |
| driver()->DeleteNode(virtual_images.inlinable_candidate.element); |
| |
| InsertPlaceholderDebugComment(virtual_images.fullsized_candidate, |
| " full-sized"); |
| driver()->DeleteNode(virtual_images.fullsized_candidate.element); |
| |
| orig_element->DeleteAttribute(HtmlName::kDataPagespeedResponsiveTemp); |
| orig_element->DeleteAttribute(HtmlName::kDataActualHeight); |
| orig_element->DeleteAttribute(HtmlName::kDataActualWidth); |
| } |
| |
| void ResponsiveImageSecondFilter::EndDocument() { |
| if (zoom_filter_enabled_ && srcsets_added_) { |
| HtmlElement* script = driver()->NewElement(NULL, HtmlName::kScript); |
| driver()->AddAttribute(script, HtmlName::kSrc, responsive_js_url_); |
| InsertNodeAtBodyEnd(script); |
| } |
| } |
| |
| } // namespace net_instaweb |