Responsive images: picturefill type attribute

Tags:

I'm working on a new responsive site and decided to finally delve into the responsive image quagmire. I've been following the goings-on for awhile, but not in any great detail. After doing some research, I wanted to focus on two approaches: first, using inline SVGs whenever possible, so the images will scale and look great at any size; second, when SVGs aren't an option, conditionally loading different size images based on media queries.

Scott Jehl's picturefill script seemed like a good starting point; picturefill is a JavaScript polyfill for the proposed new <picture> element. <picture> is interesting because, like the existing <video> tag, it uses <source> tags for media. Unlike <video>, though, the current <picture> proposal doesn't recognize type attributes on <source> tags.

<div data-picture data-alt="A giant stone face at The Bayon temple in Angkor Thom, Cambodia"> <div data-src="small.jpg"></div> <div data-src="medium.jpg" data-media="(min-width: 400px)"></div> <div data-src="large.jpg" data-media="(min-width: 800px)"></div> <div data-src="extralarge.jpg" data-media="(min-width: 1000px)"></div> <!-- Fallback content for non-JS browsers. Same img src as the initial, unqualified source element. --> <noscript> <img src="external/imgs/small.jpg" alt="A giant stone face at The Bayon temple in Angkor Thom, Cambodia"> </noscript> </div>
Figure 1: HTML for picturefill.

With <video>, the browser will load the first <source> with a compatible type. A type attribute on <picture> would allow me to load an SVG if it was supported, and fall back on JPEGs with media queries if it wasn't, as in Figure 2. This has been discussed at the Responsive Images Community Group in a blog post by Brett Jankord.

<div data-picture data-alt="A giant stone face at The Bayon temple in Angkor Thom, Cambodia"> <!-- If browser supports inline SVG, use image below: --> <div data-src="external/imgs/vector.svg" data-type="image/svg+xml"></div> <!-- Otherwise, fallback on JPEGs --> <div data-src="external/imgs/small.jpg"></div> <div data-src="external/imgs/medium.jpg" data-media="(min-width: 400px)"></div> <div data-src="external/imgs/large.jpg" data-media="(min-width: 800px)"></div> <div data-src="external/imgs/extralarge.jpg" data-media="(min-width: 1000px)"></div> <!-- Fallback content for non-JS browsers. Same img src as the initial, unqualified source element. --> <noscript><img src="external/imgs/small.jpg" alt="A giant stone face at The Bayon temple in Angkor Thom, Cambodia"></noscript> </div>
Figure 2: Using a type attribute to load an SVG if it's supported

With this strategy in mind, it was relatively simple to modify picturefill to accomodate a type attribute. I included a function to detect whether support for SVG was available, with the actual detection based on Modernizr's inlinesvg test. (I also threw in a test for WebP, again based on Modernizr.) Now when looping through the source elements, the script will match on media with no type, image/svg+xml if supported, and image/webp if supported. (Additional types for JPEG, PNG, and GIF could easily be added, but I chose to just leave out the type attribute for graphics with [near-] universal support.)

<div data-picture data-alt="A giant stone face at The Bayon temple in Angkor Thom, Cambodia"> <!-- If browser supports inline SVG, use image below: --> <div data-src="external/imgs/vector.svg" data-type="image/svg+xml"></div> <!-- Otherwise, if browser supports WebP, use images below: --> <div data-src="external/imgs/small.webp" data-type="image/webp"></div> <div data-src="external/imgs/medium.webp" data-media="(min-width: 400px)" data-type="image/webp"></div> <div data-src="external/imgs/large.webp" data-media="(min-width: 800px)" data-type="image/webp"></div> <div data-src="external/imgs/extralarge.webp" data-media="(min-width: 1000px)" data-type="image/webp"></div> <!-- Otherwise, fallback on JPEGs --> <div data-src="external/imgs/small.jpg"></div> <div data-src="external/imgs/medium.jpg" data-media="(min-width: 400px)"></div> <div data-src="external/imgs/large.jpg" data-media="(min-width: 800px)"></div> <div data-src="external/imgs/extralarge.jpg" data-media="(min-width: 1000px)"></div> <!-- Fallback content for non-JS browsers. Same img src as the initial, unqualified source element. --> <noscript><img src="external/imgs/small.jpg" alt="A giant stone face at The Bayon temple in Angkor Thom, Cambodia"></noscript> </div>
Figure 3: Using a type attribute to load an SVG or WebPs if they're supported

In the example in Figure 3, we can expect the following results:

  1. If SVG is supported: the SVG will be loaded and all other sources will be ignored.
  2. If SVG is not supported but WebP is: the SVG will be ignored, the WebP images will be loaded based on their media queries, and the JPEGs will be ignored.
  3. If neither SVG or WebP are supported: the JPEGs will be loaded based on their media queries.
  4. If media queries aren't supported or if JavaScript is turned off, the basic <img> tag will be used.

I've created a new fork of picturefill with these changes. Feel free to use this, and let me know what you think in the comments.

Updates

  1. Update @ : I just merged in Mat Marquis's fork with Florian's Compromise, and also split the files into a <div> version and a <picture>/<source> version. Updated at GitHub.
  2. Update @ Since I'm working on this for a WordPress blog, I've also now updated the GitHub repo to include a picturefill WordPress plugin.

Vote for alphaPun.ch!

Tags:

Hey guys. alphaPun.ch has been officially entered into the 10K Apart contest and is now live on their site. To vote for it, please go to the alphPun.ch entry page and tweet, like and comment! I need your support! Thank you!

Introducing alphaPun.ch

Tags:

This post is to introduce a new project I’ve been working on, alphapun.ch, which I made as an entry for the 10K Apart contest.

Alphapun.ch is a tool that, given a transparent PNG or GIF file, will trace the opaque bits and create masks for them. You can use these masks to allow a user to click through the transparent bits of an image. Alphapun.ch outputs HTML, CSS and JavaScript for you to include on your site.

Background

Graphics on the web are based on boxes. This is naturally limiting, but luckily we are able to circumvent this limitation by applying transparent backgrounds to our images. Although any image object we display will still be a square or a rectangle, these transparent backgrounds can give the illusion of an irregular shape.

Most of the time this is a very good system, but there are edge cases where it may not be flexible enough. Sometimes we don’t want to just be able to see through a transparent background, we want to be able to click through it as well.

The problem of clicking through transparent images is something that first started to bother me back in 2009. At the time I was creating what was basically a paper doll site, where users could drag and drop different articles of clothing onto a doll. The clothing and accessories were all PNGs with transparent backgrounds. The site used jQuery UI to allow users to click and drag individual images.

The problem occurred in a situation like Figure 1, where two images didn’t appear to overlap, but actually did because of the transparent backgrounds. Assume that in Figure 1 the hat and sunglasses are two separate images with transparent backgrounds, and that the stacking order on the page has the hat in front of the sunglasses.

overlapping hat and sunglasses
Figure 1: despite appearances, these images overlap
Art by Ham

A user might want to click and drag the glasses, and would rightfully think that they could. However a click on what appears to be the glasses image would actually register a click on the transparent part of the hat image. This means a user would unexpectedly start dragging the wrong article of clothing. I thought this unexpected behaviour was unacceptable and looked for a solution.

Initial Solution, or “The Hard Way”

Solving the problem involved three steps. First, I needed to be able to identify and define the opaque and transparent parts of the image, so that these could be treated differently. To do this, you can use Adobe Fireworks to generate a set of coordinates defining the opaque parts of the image. In Fireworks CS5:

  1. import an image with a transparent background
  2. select the opaque bits using the magic wand or other tools
  3. convert the selection to a path by choosing Select > Convert Marquee to Path
  4. convert the path to a hotspot by right-clicking it and choosing Insert Hotspot
  5. choose File > Export… and export the HTML
  6. the coordinates defining the shape/path/hotspot will be inside the generated HTML document
image with opaque part selected image with path image with hotspot … <area shape="poly" coords="76,1,83,6,89,13,98,29,108,22,116,22,126,24,132,31,135,40,136,49,134,60,129,72,120,82,107,89,100,92,108,67,107,61,104,57,95,52,85,51,77,51,59,51,42,55,37,56,33,58,29,62,29,66,33,81,40,94,22,89,10,80,3,69,1,56,2,44,6,33,14,26,19,23,25,23,37,27,41,23,46,15,53,6,61,1,76,1,76,1" href="javascript:;" alt="" /> …
Figure 2: Getting coordinates from Adobe Fireworks

This is tedious, and becomes complicated when you have multiple shapes in a single image, or when you have shapes with holes in them. But, it works.

Armed with a set of coordinates to define the opaque shape, the second step was to figure out what to do with them. Drawing the shape on a canvas was not really an option at the time, and even if I could do that, there was no obvious way it could solve my problem. I needed to draw the shape as a real DOM object that could register click events, and that wasn’t another rectangular box overlapping my content.

I ended up stumbling onto a solution in Walter Zorn’s wz_jsgraphics library (which I’ve made available on GitHub). This library was created as an early polyfill for canvas, and included a function called fillPolygon that, when given a set of coordinates, would draw the shape defined by those coordinates using a large number of small div elements. The result has the desired shape, is a set of real DOM objects, and doesn’t have any problematic transparent backgrounds to confuse users.

polygonal mask made of div elements
Figure 3: Zoomed-in example of a filled polygon from wz_jsgraphics, made from divs

The third and final step was to tie this new wz_jsgraphics mask to the original images somehow. The solution to this was to make the divs in the mask transparent, overlay them directly on top of their source image, and to use jQuery UI’s super-useful handle option to define the mask as a handle for the image. Some messing about with z-index and position to insure proper stacking orders (with masks always on top of images), and everything worked beautifully.

glasses img - hat img - glasses mask - hat mask
Figure 4: stacking order of images and masks

With this solution, when a user appeared to be clicking on the sunglasses, they would actually be clicking not on the glasses image, and not on the hat image, but on a set of transparent divs that act as a mask and handle for the sunglasses. To the user, this would be seamless, and they would be able to drag the glasses onto the doll without being blocked by the hat.

alphaPun.ch, or “The Easy Way”

In updating my 2009 solution and making alphaPun.ch, I wanted to update and simplify each part of the three step process outlined above.

First, I wanted to be able to automatically generate the set of coordinates defining the opaque part of the shape. AlphaPun.ch traces shapes using an alphaPunchPencil (APP) object.

var p = new APP(); // create the alphaPunchPencil object p.img = src; // assign it an image to trace p.iw = p.img.offsetWidth + 4; // pad the image width p.ih = p.img.offsetHeight + 4; // pad the image height try { p.fmc(); } catch(e1) { err(''); return bf; } // find a missing colour to use in tracing try { p.fpo(); } catch(e2) { err(''); return bf; } // trace opaque shapes try { p.fpt(); } catch(e3) { err(''); return bf; } // trace transparent shapes within opaque shapes (i.e. holes) try { p.cp(); } catch(e4) { err(''); return bf; } // combine paths of opaque and transparent shapes
Figure 5: Code to trace an image

Once an image is uploaded, the tool draws it onto a canvas. The opaque parts of the image are traced using a Moore-Neighbor tracing algorithm, based in part on Abeer Ghuneim’s tutorial. This eliminates the need to launch Fireworks and manually generate coordinates, and it works when there are multiple objects in an image and when there are objects with holes.

Next, I wanted to replace wz_jsgraphics for drawing the masks. It worked very well, but it had far too many features for my needs, and I didn’t want to rely on any external libraries other than jQuery. An alphaPunchFist (APF) object is used to create and place the masks.

$('.alphapunch').each( function() { // find all elements with the 'alphapunch' class var f = new APF(); // create a new alphaPunchFist object $(this).find('.aptarget').each( function() { // find all elements with the 'aptarget' class $(this).wrap(''); // wrap the target in a container span $(this).append(''); // add another span for the mask f.c = dge('apmask_' + this.id); // set that span as a container for the mask f.ds(0,0,this.offsetWidth,this.offsetHeight); // draw the (simple) mask }); $(this).find('img').each( function() { // find all images $(this).wrap(''); // wrap them in a container span $(this).after(''); // add another span for the mask f.c = dge('apmask_' + this.id); // set that span as a container for the mask f.iw = this.offsetWidth; // set the image width f.ih = this.offsetHeight; // set the image height f.p = coords[this.id]; // grab the coordinates that define the mask f.f(); // draw the mask by making a filled polygon }); });
Figure 6: Code to draw an alphaPun.ch masks

I wrote my own scanline-based replacement for the fillPolygon() function (f.f() in Figure 6), based in part on the algorithms in the Polygon Fill Teaching Tool (which, unfortunately, doesn’t list an author). This new function draws spans rather than divs, which means they can be properly embedded in inline elements, such as a.

Finally, I wanted to update the third step so that, rather than manually fiddling with styles for individual objects and trying to figure out stacking orders, the tool could spit out some (relatively) simple HTML, CSS and JavaScript that a web dev could throw on a page for immediate functionality. I imagine that the most popular use case will be clicking through an image to a link beneath it, so that’s what the generated code does.

<!-- container indicating alphapunching --> <div class="alphapunch"> <!-- image you alphaPunch'd. needs an ID if you run into trouble positioning it, put it in a container element, like <figure> --> <figure><img id="hat_01" src="hat_01.png" alt="hat_01" /></figure> <!-- "target" links you want to click through to. they all need IDs. if you run into trouble positioning them, put them in a container element, like <nav> or <ul> or <div> --> <ul> <li><a class="aptarget" id="foo" href="javascript:alert('foo')">foo</a></li> <li><a class="aptarget" id="bar" href="javascript:alert('bar')">bar</a></li> </ul> </div> <!-- alphapunch uses some jQuery; it must be present --> <script src="//ajax.googleapis.com/ajax/libs/jquery/1.6.2/jquery.min.js"></script>
Figure 7: HTML used when clicking through to a link

The HTML simply places your image and the links in a container element with the class alphapunch. The links have the class aptarget, and the images and links all have IDs. The JS will find instances of the alphapunch class, create masks for the images by matching them with a set of coordinates (based on ID), and create masks for links with the aptarget class.

/* stacking order from back-to-front is: 1. links 2. image 3. link masks 4. image mask IE requires a BG to register click event. */ .alphapunch { position: relative; } .alphapunch img { position: relative; z-index: 2; } .aptarget { display: inline-block; } .apmask span { z-index: 3; background: rgba(0,0,0,0.001); } img + .apmask span { z-index: 4; }
Figure 8: CSS used when clicking through to a link

The CSS sets up the stacking order of the image, the links, and the masks. The order from back to front is: links, image, link masks, image mask. Note that the masks must have a background for them to register click events in IE.

(function() { var coords = { hat_01: [{ x: 39, y: 91 }, { x: 39, y: 93 }, { x: 40, y: 94 }, { x: 40, y: 95 }, { x: 39, y: 94 }, { x: 36, y: 94 }, { x: 35, y: 93 }, { x: 33, y: 93 }, { x: 32, y: 92 }, { x: 30, y: 92 }, { x: 29, y: 91 }, { x: 27, y: 91 }, { x: 26, y: 90 }, { x: 25, y: 90 }, { x: 24, y: 89 }, { x: 23, y: 89 }, { x: 22, y: 88 }, { x: 21, y: 88 }, { x: 20, y: 87 }, { x: 19, y: 87 }, { x: 18, y: 86 }, { x: 17, y: 85 }, { x: 16, y: 84 }, { x: 15, y: 84 }, { x: 14, y: 83 }, { x: 13, y: 82 }, { x: 12, y: 82 }, { x: 11, y: 81 }, { x: 10, y: 80 }, { x: 10, y: 79 }, { x: 9, y: 78 }, { x: 8, y: 77 }, { x: 8, y: 76 }, { x: 7, y: 75 }, { x: 6, y: 74 }, { x: 6, y: 72 }, { x: 5, y: 71 }, { x: 5, y: 69 }, { x: 4, y: 68 }, { x: 4, y: 66 }, { x: 3, y: 65 }, { x: 3, y: 61 }, { x: 2, y: 60 }, { x: 2, y: 57 }, { x: 3, y: 56 }, { x: 3, y: 51 }, { x: 4, y: 50 }, { x: 4, y: 46 }, { x: 5, y: 45 }, { x: 5, y: 42 }, { x: 6, y: 41 }, { x: 6, y: 40 }, { x: 7, y: 39 }, { x: 7, y: 38 }, { x: 8, y: 37 }, { x: 9, y: 36 }, { x: 9, y: 35 }, { x: 10, y: 34 }, { x: 10, y: 33 }, { x: 11, y: 32 }, { x: 12, y: 31 }, { x: 12, y: 30 }, { x: 13, y: 29 }, { x: 14, y: 29 }, { x: 15, y: 28 }, { x: 16, y: 27 }, { x: 18, y: 27 }, { x: 19, y: 26 }, { x: 20, y: 26 }, { x: 21, y: 25 }, { x: 24, y: 25 }, { x: 25, y: 24 }, { x: 28, y: 24 }, { x: 29, y: 25 }, { x: 32, y: 25 }, { x: 33, y: 26 }, { x: 34, y: 26 }, { x: 35, y: 27 }, { x: 36, y: 27 }, { x: 37, y: 28 }, { x: 38, y: 29 }, { x: 40, y: 29 }, { x: 41, y: 28 }, { x: 42, y: 27 }, { x: 42, y: 26 }, { x: 43, y: 25 }, { x: 44, y: 24 }, { x: 44, y: 23 }, { x: 45, y: 22 }, { x: 46, y: 21 }, { x: 46, y: 20 }, { x: 47, y: 19 }, { x: 47, y: 18 }, { x: 48, y: 17 }, { x: 49, y: 16 }, { x: 50, y: 15 }, { x: 51, y: 14 }, { x: 51, y: 13 }, { x: 52, y: 12 }, { x: 53, y: 11 }, { x: 53, y: 10 }, { x: 54, y: 9 }, { x: 55, y: 8 }, { x: 56, y: 7 }, { x: 57, y: 6 }, { x: 57, y: 5 }, { x: 58, y: 4 }, { x: 59, y: 3 }, { x: 67, y: 3 }, { x: 68, y: 4 }, { x: 72, y: 4 }, { x: 73, y: 3 }, { x: 76, y: 3 }, { x: 77, y: 2 }, { x: 78, y: 3 }, { x: 80, y: 3 }, { x: 81, y: 4 }, { x: 82, y: 5 }, { x: 83, y: 6 }, { x: 83, y: 7 }, { x: 84, y: 8 }, { x: 85, y: 9 }, { x: 85, y: 10 }, { x: 86, y: 11 }, { x: 87, y: 12 }, { x: 88, y: 13 }, { x: 89, y: 14 }, { x: 90, y: 15 }, { x: 91, y: 16 }, { x: 92, y: 17 }, { x: 92, y: 18 }, { x: 93, y: 19 }, { x: 94, y: 20 }, { x: 94, y: 22 }, { x: 95, y: 23 }, { x: 95, y: 25 }, { x: 96, y: 26 }, { x: 96, y: 27 }, { x: 97, y: 28 }, { x: 97, y: 29 }, { x: 98, y: 30 }, { x: 99, y: 31 }, { x: 100, y: 30 }, { x: 101, y: 30 }, { x: 102, y: 29 }, { x: 103, y: 29 }, { x: 104, y: 28 }, { x: 105, y: 28 }, { x: 106, y: 27 }, { x: 107, y: 27 }, { x: 108, y: 26 }, { x: 109, y: 25 }, { x: 111, y: 25 }, { x: 112, y: 24 }, { x: 116, y: 24 }, { x: 117, y: 23 }, { x: 119, y: 23 }, { x: 120, y: 24 }, { x: 123, y: 24 }, { x: 124, y: 25 }, { x: 126, y: 25 }, { x: 127, y: 26 }, { x: 128, y: 27 }, { x: 129, y: 28 }, { x: 130, y: 29 }, { x: 131, y: 30 }, { x: 132, y: 31 }, { x: 132, y: 32 }, { x: 133, y: 33 }, { x: 133, y: 35 }, { x: 134, y: 36 }, { x: 134, y: 38 }, { x: 135, y: 39 }, { x: 135, y: 42 }, { x: 136, y: 43 }, { x: 136, y: 48 }, { x: 137, y: 49 }, { x: 137, y: 52 }, { x: 136, y: 53 }, { x: 136, y: 58 }, { x: 135, y: 59 }, { x: 135, y: 63 }, { x: 134, y: 64 }, { x: 134, y: 66 }, { x: 133, y: 67 }, { x: 133, y: 68 }, { x: 132, y: 69 }, { x: 132, y: 71 }, { x: 131, y: 72 }, { x: 130, y: 73 }, { x: 130, y: 74 }, { x: 129, y: 75 }, { x: 129, y: 76 }, { x: 128, y: 77 }, { x: 127, y: 78 }, { x: 126, y: 79 }, { x: 125, y: 80 }, { x: 124, y: 81 }, { x: 123, y: 82 }, { x: 122, y: 83 }, { x: 121, y: 84 }, { x: 120, y: 85 }, { x: 118, y: 85 }, { x: 117, y: 86 }, { x: 116, y: 86 }, { x: 115, y: 87 }, { x: 114, y: 88 }, { x: 112, y: 88 }, { x: 111, y: 89 }, { x: 109, y: 89 }, { x: 108, y: 90 }, { x: 105, y: 90 }, { x: 104, y: 91 }, { x: 103, y: 91 }, { x: 102, y: 92 }, { x: 102, y: 91 }, { x: 103, y: 90 }, { x: 103, y: 89 }, { x: 104, y: 88 }, { x: 104, y: 84 }, { x: 105, y: 83 }, { x: 105, y: 81 }, { x: 106, y: 80 }, { x: 106, y: 77 }, { x: 107, y: 76 }, { x: 107, y: 72 }, { x: 108, y: 71 }, { x: 109, y: 70 }, { x: 109, y: 63 }, { x: 108, y: 62 }, { x: 108, y: 58 }, { x: 107, y: 57 }, { x: 106, y: 56 }, { x: 105, y: 56 }, { x: 104, y: 55 }, { x: 103, y: 55 }, { x: 102, y: 54 }, { x: 100, y: 54 }, { x: 99, y: 53 }, { x: 96, y: 53 }, { x: 95, y: 52 }, { x: 88, y: 52 }, { x: 87, y: 51 }, { x: 69, y: 51 }, { x: 68, y: 52 }, { x: 58, y: 52 }, { x: 57, y: 53 }, { x: 52, y: 53 }, { x: 51, y: 54 }, { x: 49, y: 54 }, { x: 48, y: 55 }, { x: 45, y: 55 }, { x: 44, y: 56 }, { x: 40, y: 56 }, { x: 39, y: 57 }, { x: 37, y: 57 }, { x: 36, y: 58 }, { x: 34, y: 58 }, { x: 33, y: 59 }, { x: 32, y: 60 }, { x: 32, y: 61 }, { x: 31, y: 62 }, { x: 31, y: 64 }, { x: 30, y: 65 }, { x: 29, y: 66 }, { x: 29, y: 69 }, { x: 30, y: 70 }, { x: 30, y: 71 }, { x: 31, y: 72 }, { x: 31, y: 74 }, { x: 32, y: 75 }, { x: 32, y: 77 }, { x: 33, y: 78 }, { x: 33, y: 82 }, { x: 34, y: 83 }, { x: 34, y: 84 }, { x: 35, y: 85 }, { x: 36, y: 86 }, { x: 37, y: 87 }, { x: 38, y: 88 }, { x: 38, y: 90 }, { x: 39, y: 91 }, { x: 39, y: 93 }] }; var APF=function(){var i=document;this.cnv=i.createElement("canvas");this.ctx=this.cnv.getContext("2d");this.ih=this.iw=0;this.p=[];this.c=null;this.es= function(a,b){return a.ymn===b.ymn?a.xvl===b.xvl?a.ymx-b.ymx:a.xvl-b.xvl:a.ymn-b.ymn};this.ds=function(a,b,c,d){var e=i.createElement("span");d+=2;e.style.cssText="position:absolute;top:"+b+"px;left:"+a+"px;width:"+c+"px;height:"+d+"px;";this.c.appendChild(e)};this.f=function(){var a=[],b=[],c,d,e=[],f,h,i,j=0,k=d=0;d=1;var l=0,m=0;this.c.innerHTML="";for(f=0;f<this.p.length;f++){c={};h=this.p[f];i=f===this.p.length-1?this.p[0]:this.p[f+1];c.ymx=h.y;if(i.y>c.ymx)c.ymx=i.y;if(c.ymx>m)m=c.ymx;c.ymn= h.y;if(i.y<c.ymn)c.ymn=i.y;if(c.ymn<l)l=c.ymn;c.xvl=parseInt(h.x,10);if(i.y<h.y)c.xvl=parseInt(i.x,10);if(c.xvl>j)j=c.xvl;d=h.x-i.x;d===0?(c.m=NaN,c.oom=0):(c.m=(h.y-i.y)/d,c.oom=c.m===0?NaN:1/c.m);b.push(c)}for(f=0;f<b.length;f++)b[f].m!==0&&e.push(b[f]);e.sort(this.es);for(b=e[0].ymn;b<m;b++){d=0;g=[];for(f=0;f<e.length;f++)e[f].ymn===b?a.push(e[f]):g.push(e[f]);e=g.slice(0);a.sort(this.es);for(c=0;c<=j;c++)for(f=0;f<a.length;f++)Math.round(a[f].xvl)===c&&(d===0?(k=c,d=1):(d=c,d-=k,d===0&&(d=1), this.ds(k,b,d,1),d=0));g=[];for(f=0;f<a.length;f++)a[f].ymx!==b+1&&(a[f].xvl+=a[f].oom,g.push(a[f]));a=g.slice(0)}}}; $('.alphapunch').each( function() { var fist = new APF(); $(this).find('.aptarget').each( function() { $(this).wrap('<span style="display:inline-block;position:relative" />'); $(this).append('<span class="apmask" id="apmask_' + this.id + '"></span>'); fist.c = document.getElementById('apmask_' + this.id); fist.ds(0,0,this.offsetWidth,this.offsetHeight); }); $(this).find('img').each( function() { $(this).wrap('<span style="display:inline-block;position:relative" />'); $(this).after('<span class="apmask" id="apmask_' + this.id + '"></span>'); fist.c = document.getElementById('apmask_' + this.id); fist.iw = this.offsetWidth; fist.ih = this.offsetHeight; fist.p = coords[this.id]; fist.f(); $('#apmask_' + this.id).click( function() { $('#' + this.id).click(); return false; }); }); }); })();
Figure 9: JavaScript used when clicking through to a link

The JS includes the coordinates for your image, the APF object, and some code telling the APF to create the masks.

That’s it!

Figure 10: Live demo of one image with multiple links

As mentioned above, this code will allow you to click through to links behind a single image, but alphaPun.ch also allows for some flexibility. For example, you could add more images within the .alphapunch container, you could adjust the CSS to change around z-indexes, or you could set .aptargets that aren’t links.

To create the paper doll functionality that started all of this, we just need to add extra images into our .alphapunch div, add coordinates for the extra images into our var coords object, and call the jQuery UI draggable() function with appropriate handles set. Everything else is standard output from alphaPunch.

<!-- container indicating alphapunching --> <div class="alphapunch"> <!-- image you alphaPunch'd. needs an ID if you run into trouble positioning it, put it in a container element, like <figure> --> <figure><img id="accessory_02" src="accessory_02.png" alt="accessory_02" /></figure> <figure><img id="hat_02" src="hat_02.png" alt="hat_02" /></figure> <!-- "target" links you want to click through to. they all need IDs. if you run into trouble positioning them, put them in a container element, like <nav> or <ul> or <div> --> <!-- this example doesn't need any target links --> </div> <!-- alphapunch uses some jQuery; it must be present --> <!-- the paper doll example needs jQuery UI --> <script src="//ajax.googleapis.com/ajax/libs/jquery/1.6.2/jquery.min.js"></script> <script src="//ajax.googleapis.com/ajax/libs/jqueryui/1.8.16/jquery-ui.min.js"></script> <script> (function() { var coords = { accessory_02: [{ x: 21, y: 21 }, { x: 18, y: 21 }, { x: 17, y: 22 }, { x: 12, y: 22 }, { x: 11, y: 21 }, { x: 10, y: 20 }, { x: 9, y: 19 }, { x: 8, y: 18 }, { x: 8, y: 17 }, { x: 7, y: 16 }, { x: 7, y: 14 }, { x: 6, y: 13 }, { x: 6, y: 11 }, { x: 5, y: 10 }, { x: 4, y: 9 }, { x: 2, y: 9 }, { x: 2, y: 8 }, { x: 3, y: 7 }, { x: 4, y: 6 }, { x: 5, y: 6 }, { x: 6, y: 5 }, { x: 9, y: 5 }, { x: 10, y: 4 }, { x: 23, y: 4 }, { x: 24, y: 5 }, { x: 26, y: 5 }, { x: 27, y: 6 }, { x: 33, y: 6 }, { x: 34, y: 5 }, { x: 35, y: 5 }, { x: 36, y: 4 }, { x: 37, y: 4 }, { x: 38, y: 3 }, { x: 45, y: 3 }, { x: 46, y: 2 }, { x: 50, y: 2 }, { x: 51, y: 3 }, { x: 55, y: 3 }, { x: 56, y: 4 }, { x: 57, y: 4 }, { x: 58, y: 5 }, { x: 59, y: 6 }, { x: 58, y: 7 }, { x: 57, y: 7 }, { x: 56, y: 8 }, { x: 56, y: 9 }, { x: 55, y: 10 }, { x: 55, y: 13 }, { x: 54, y: 14 }, { x: 54, y: 16 }, { x: 53, y: 17 }, { x: 52, y: 18 }, { x: 52, y: 19 }, { x: 51, y: 20 }, { x: 40, y: 20 }, { x: 39, y: 19 }, { x: 38, y: 18 }, { x: 37, y: 17 }, { x: 36, y: 16 }, { x: 35, y: 15 }, { x: 35, y: 13 }, { x: 34, y: 12 }, { x: 34, y: 11 }, { x: 33, y: 10 }, { x: 33, y: 9 }, { x: 32, y: 8 }, { x: 30, y: 8 }, { x: 29, y: 9 }, { x: 28, y: 10 }, { x: 28, y: 11 }, { x: 27, y: 12 }, { x: 27, y: 14 }, { x: 26, y: 15 }, { x: 26, y: 17 }, { x: 25, y: 18 }, { x: 24, y: 19 }, { x: 23, y: 20 }, { x: 22, y: 20 }, { x: 21, y: 21 }, { x: 18, y: 21 }], hat_02: [{ x: 39, y: 91 }, { x: 39, y: 93 }, { x: 40, y: 94 }, { x: 40, y: 95 }, { x: 39, y: 94 }, { x: 36, y: 94 }, { x: 35, y: 93 }, { x: 33, y: 93 }, { x: 32, y: 92 }, { x: 30, y: 92 }, { x: 29, y: 91 }, { x: 27, y: 91 }, { x: 26, y: 90 }, { x: 25, y: 90 }, { x: 24, y: 89 }, { x: 23, y: 89 }, { x: 22, y: 88 }, { x: 21, y: 88 }, { x: 20, y: 87 }, { x: 19, y: 87 }, { x: 18, y: 86 }, { x: 17, y: 85 }, { x: 16, y: 84 }, { x: 15, y: 84 }, { x: 14, y: 83 }, { x: 13, y: 82 }, { x: 12, y: 82 }, { x: 11, y: 81 }, { x: 10, y: 80 }, { x: 10, y: 79 }, { x: 9, y: 78 }, { x: 8, y: 77 }, { x: 8, y: 76 }, { x: 7, y: 75 }, { x: 6, y: 74 }, { x: 6, y: 72 }, { x: 5, y: 71 }, { x: 5, y: 69 }, { x: 4, y: 68 }, { x: 4, y: 66 }, { x: 3, y: 65 }, { x: 3, y: 61 }, { x: 2, y: 60 }, { x: 2, y: 57 }, { x: 3, y: 56 }, { x: 3, y: 51 }, { x: 4, y: 50 }, { x: 4, y: 46 }, { x: 5, y: 45 }, { x: 5, y: 42 }, { x: 6, y: 41 }, { x: 6, y: 40 }, { x: 7, y: 39 }, { x: 7, y: 38 }, { x: 8, y: 37 }, { x: 9, y: 36 }, { x: 9, y: 35 }, { x: 10, y: 34 }, { x: 10, y: 33 }, { x: 11, y: 32 }, { x: 12, y: 31 }, { x: 12, y: 30 }, { x: 13, y: 29 }, { x: 14, y: 29 }, { x: 15, y: 28 }, { x: 16, y: 27 }, { x: 18, y: 27 }, { x: 19, y: 26 }, { x: 20, y: 26 }, { x: 21, y: 25 }, { x: 24, y: 25 }, { x: 25, y: 24 }, { x: 28, y: 24 }, { x: 29, y: 25 }, { x: 32, y: 25 }, { x: 33, y: 26 }, { x: 34, y: 26 }, { x: 35, y: 27 }, { x: 36, y: 27 }, { x: 37, y: 28 }, { x: 38, y: 29 }, { x: 40, y: 29 }, { x: 41, y: 28 }, { x: 42, y: 27 }, { x: 42, y: 26 }, { x: 43, y: 25 }, { x: 44, y: 24 }, { x: 44, y: 23 }, { x: 45, y: 22 }, { x: 46, y: 21 }, { x: 46, y: 20 }, { x: 47, y: 19 }, { x: 47, y: 18 }, { x: 48, y: 17 }, { x: 49, y: 16 }, { x: 50, y: 15 }, { x: 51, y: 14 }, { x: 51, y: 13 }, { x: 52, y: 12 }, { x: 53, y: 11 }, { x: 53, y: 10 }, { x: 54, y: 9 }, { x: 55, y: 8 }, { x: 56, y: 7 }, { x: 57, y: 6 }, { x: 57, y: 5 }, { x: 58, y: 4 }, { x: 59, y: 3 }, { x: 67, y: 3 }, { x: 68, y: 4 }, { x: 72, y: 4 }, { x: 73, y: 3 }, { x: 76, y: 3 }, { x: 77, y: 2 }, { x: 78, y: 3 }, { x: 80, y: 3 }, { x: 81, y: 4 }, { x: 82, y: 5 }, { x: 83, y: 6 }, { x: 83, y: 7 }, { x: 84, y: 8 }, { x: 85, y: 9 }, { x: 85, y: 10 }, { x: 86, y: 11 }, { x: 87, y: 12 }, { x: 88, y: 13 }, { x: 89, y: 14 }, { x: 90, y: 15 }, { x: 91, y: 16 }, { x: 92, y: 17 }, { x: 92, y: 18 }, { x: 93, y: 19 }, { x: 94, y: 20 }, { x: 94, y: 22 }, { x: 95, y: 23 }, { x: 95, y: 25 }, { x: 96, y: 26 }, { x: 96, y: 27 }, { x: 97, y: 28 }, { x: 97, y: 29 }, { x: 98, y: 30 }, { x: 99, y: 31 }, { x: 100, y: 30 }, { x: 101, y: 30 }, { x: 102, y: 29 }, { x: 103, y: 29 }, { x: 104, y: 28 }, { x: 105, y: 28 }, { x: 106, y: 27 }, { x: 107, y: 27 }, { x: 108, y: 26 }, { x: 109, y: 25 }, { x: 111, y: 25 }, { x: 112, y: 24 }, { x: 116, y: 24 }, { x: 117, y: 23 }, { x: 119, y: 23 }, { x: 120, y: 24 }, { x: 123, y: 24 }, { x: 124, y: 25 }, { x: 126, y: 25 }, { x: 127, y: 26 }, { x: 128, y: 27 }, { x: 129, y: 28 }, { x: 130, y: 29 }, { x: 131, y: 30 }, { x: 132, y: 31 }, { x: 132, y: 32 }, { x: 133, y: 33 }, { x: 133, y: 35 }, { x: 134, y: 36 }, { x: 134, y: 38 }, { x: 135, y: 39 }, { x: 135, y: 42 }, { x: 136, y: 43 }, { x: 136, y: 48 }, { x: 137, y: 49 }, { x: 137, y: 52 }, { x: 136, y: 53 }, { x: 136, y: 58 }, { x: 135, y: 59 }, { x: 135, y: 63 }, { x: 134, y: 64 }, { x: 134, y: 66 }, { x: 133, y: 67 }, { x: 133, y: 68 }, { x: 132, y: 69 }, { x: 132, y: 71 }, { x: 131, y: 72 }, { x: 130, y: 73 }, { x: 130, y: 74 }, { x: 129, y: 75 }, { x: 129, y: 76 }, { x: 128, y: 77 }, { x: 127, y: 78 }, { x: 126, y: 79 }, { x: 125, y: 80 }, { x: 124, y: 81 }, { x: 123, y: 82 }, { x: 122, y: 83 }, { x: 121, y: 84 }, { x: 120, y: 85 }, { x: 118, y: 85 }, { x: 117, y: 86 }, { x: 116, y: 86 }, { x: 115, y: 87 }, { x: 114, y: 88 }, { x: 112, y: 88 }, { x: 111, y: 89 }, { x: 109, y: 89 }, { x: 108, y: 90 }, { x: 105, y: 90 }, { x: 104, y: 91 }, { x: 103, y: 91 }, { x: 102, y: 92 }, { x: 102, y: 91 }, { x: 103, y: 90 }, { x: 103, y: 89 }, { x: 104, y: 88 }, { x: 104, y: 84 }, { x: 105, y: 83 }, { x: 105, y: 81 }, { x: 106, y: 80 }, { x: 106, y: 77 }, { x: 107, y: 76 }, { x: 107, y: 72 }, { x: 108, y: 71 }, { x: 109, y: 70 }, { x: 109, y: 63 }, { x: 108, y: 62 }, { x: 108, y: 58 }, { x: 107, y: 57 }, { x: 106, y: 56 }, { x: 105, y: 56 }, { x: 104, y: 55 }, { x: 103, y: 55 }, { x: 102, y: 54 }, { x: 100, y: 54 }, { x: 99, y: 53 }, { x: 96, y: 53 }, { x: 95, y: 52 }, { x: 88, y: 52 }, { x: 87, y: 51 }, { x: 69, y: 51 }, { x: 68, y: 52 }, { x: 58, y: 52 }, { x: 57, y: 53 }, { x: 52, y: 53 }, { x: 51, y: 54 }, { x: 49, y: 54 }, { x: 48, y: 55 }, { x: 45, y: 55 }, { x: 44, y: 56 }, { x: 40, y: 56 }, { x: 39, y: 57 }, { x: 37, y: 57 }, { x: 36, y: 58 }, { x: 34, y: 58 }, { x: 33, y: 59 }, { x: 32, y: 60 }, { x: 32, y: 61 }, { x: 31, y: 62 }, { x: 31, y: 64 }, { x: 30, y: 65 }, { x: 29, y: 66 }, { x: 29, y: 69 }, { x: 30, y: 70 }, { x: 30, y: 71 }, { x: 31, y: 72 }, { x: 31, y: 74 }, { x: 32, y: 75 }, { x: 32, y: 77 }, { x: 33, y: 78 }, { x: 33, y: 82 }, { x: 34, y: 83 }, { x: 34, y: 84 }, { x: 35, y: 85 }, { x: 36, y: 86 }, { x: 37, y: 87 }, { x: 38, y: 88 }, { x: 38, y: 90 }, { x: 39, y: 91 }, { x: 39, y: 93 }] }; var APF=function(){var h=document;this.cn=h.createElement("canvas");this.ctx=this.cn.getContext("2d");this.ih=this.iw=0;this.p=[];this.c=null;this.es=function(a,b){return a.ymn===b.ymn?a.xvl===b.xvl?a.ymx-b.ymx:a.xvl-b.xvl:a.ymn-b.ymn};this.ds=function(a,b,c,d){var e=h.createElement("span");d+=2;e.style.cssText="position:absolute;top:"+b+"px;left:"+a+"px;width:"+c+"px;height:"+d+"px;";this.c.appendChild(e)};this.f=function(){var a=[],b=[],c,d,e=[],f,i,h,j=0,k=d=0;d=1;var l=0,m=0;this.c.innerHTML="";for(f=0;f<this.p.length;f++){c={};i=this.p[f];h=f===this.p.length-1?this.p[0]:this.p[f+1];c.ymx=i.y;if(h.y>c.ymx)c.ymx=h.y;if(c.ymx>m)m=c.ymx;c.ymn=i.y;if(h.y<c.ymn)c.ymn=h.y;if(c.ymn<l)l=c.ymn;c.xvl=parseInt(i.x,10);if(h.y<i.y)c.xvl=parseInt(h.x,10);if(c.xvl>j)j=c.xvl; d=i.x-h.x;d===0?(c.m=NaN,c.oom=0):(c.m=(i.y-h.y)/d,c.oom=c.m===0?NaN:1/c.m);b.push(c)}for(f=0;f<b.length;f++)b[f].m!==0&&e.push(b[f]);e.sort(this.es);for(b=e[0].ymn;b<m;b++){d=0;g=[];for(f=0;f<e.length;f++)e[f].ymn===b?a.push(e[f]):g.push(e[f]);e=g.slice(0);a.sort(this.es);for(c=0;c<=j;c++)for(f=0;f<a.length;f++)Math.round(a[f].xvl)===c&&(d===0?(k=c,d=1):(d=c,d-=k,d===0&&(d=1),this.ds(k,b,d,1),d=0));g=[];for(f=0;f<a.length;f++)a[f].ymx!==b+1&&(a[f].xvl+=a[f].oom,g.push(a[f]));a=g.slice(0)}}}; $('.alphapunch').each( function() { var fist = new APF(); $(this).find('.aptarget').each( function() { $(this).wrap('<span style="display:inline-block;position:relative" />'); $(this).append('<span class="apmask" id="apmask_' + this.id + '"></span>'); fist.c = document.getElementById('apmask_' + this.id); fist.ds(0,0,this.offsetWidth,this.offsetHeight); }); $(this).find('img').each( function() { $(this).wrap('<span style="display:inline-block;position:relative" />'); $(this).after('<span class="apmask" id="apmask_' + this.id + '"></span>'); fist.c = document.getElementById('apmask_' + this.id); fist.iw = this.offsetWidth; fist.ih = this.offsetHeight; fist.p = coords[this.id]; fist.f(); $('#apmask_' + this.id).click( function() { $('#' + this.id).click(); return false; }); }); $('.alphapunch > figure > span').draggable({ handle: '.apmask' }); }); })(); </script>
accessory_02 hat_02
Figure 11: Code and live demo for paper doll functionality

Problems

As it stands, alphaPun.ch has a few problems:

  1. Alphapun.ch generates a metric craptonne of non-semantic spans when drawing the masks. These are not just bad for the page semantics, but all the extra elements and DOM changes can also slow down the page, especially on older browsers.
  2. When tracing shapes, alphapun.ch considers anything other than opacity 1.0 to be transparent. I really wanted to allow the user to select the opacity threshold, but this caused major slowdowns in processing and often led alphaPun.ch to crash. It’s on the TODO list.
  3. When drawing on a canvas, browsers antialias lines. This can cause problems when pixel-perfect accuracy is needed, as it is here. In order to overcome antialiasing artifacts, I needed to take some steps that lower the accuracy of the masks that get created. As a result, masks don’t perfectly match the original image, and very thin lines may not get masked at all. (Antialiasing can actually be turned off in Firefox, but I opted for relative consistancy between browsers.)
  4. Despite the parenthetical remark above, not all browsers trace shapes and create masks in exactly the same way. Chrome and Firefox seem to have the same behaviour, but IE10 will come up with different coordinates. I haven’t figured out exactly why this is, but I think IE10 is reporting pixel opacity values differently. It’s something to investigate further.
  5. Alphapun.ch uses canvas, Drag & Drop and FileReader, so any browsers that don’t support these (e.g. Safari 5.x and lower) are left out of the fun (at least for now).
  6. Processing complex images (e.g. that are very large, have lots of separate shapes, or with very complex polygons) can be processor intensive and slow. IE10 seems especially slow, but that might just be my VM.

Next steps

I had a heck of a time getting everything in under 10K for the contest, so some of the first things I’d like to do for the non-contest version are organize the code a bit differently, make it more readable, and do some cleanup.

Other than that, and the problems listed above, I’m open. Go play with it! Let me know if there’s anything you want to see, or anything you want a more detailed explanation for. Comment, or email, or tweet. It’s on GitHub, so take a look and/or fork it.

The Current State of Hyphenation on the Web

Tags:

Last September, A List Apart published ‘The Look That Says Book’, a great article by Richard Fink about hyphenation and justification on the web. It’s a great read, and I highly recommend you check it out. At the time the article was published, there were no really great solutions for hyphenation on the web; there wasn’t any support for CSS solutions, and manually- or JavaScript-injected soft hyphens and zero-width spaces often caused broken browser find-on-page functionality. Fortunately, the situation is starting to change.

CSS: hyphens

Although I wasn’t able to attend, I was very excited to follow the recent An Event Apart conference in Minneapolis via Twitter and notes posted by attendees. One thing that caught my eye was Richard Rutter’s talk, ‘Detail in Web Typography’, and a couple of tweets (1, 2) that mentioned CSS-based hyphenation. It turns out that the latest versions of some browsers support hyphenation with the following CSS:

-webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto;
Figure 1: Hyphenation CSS

Update : Firefox 6 got released as I was writing this yesterday, so there are now two browsers available supporting CSS hyphens, Firefox 6 and Safari 5.1.

This is great! Beautiful web hyphenation without relying on JavaScript and without breaking find-on-page! But what about older browsers?

JavaScript: Hyphenator.js

The JavaScript library that Fink suggests using in his ALA article is Hyphenator.js. This library is a thing of beauty. Based on a vast dictionary, it will automatically inject soft hyphens and zero-width spaces into your content; browsers that recognize these characters should hyphenate properly. New versions even support the hyphens CSS rules, and will use those when supported. The only downside is, as mentioned above and by Fink, the find-on-page issue: if you hit ⌘-F (or ctrl-F or whatever) to look for a word, most browsers wouldn’t find it because of the invisible-but-still-present soft hyphens.

Testing for support

For many, the broken find-on-page might not be a big enough reason to avoid using Hyphenator.js, but others may not want to lose that functionality. Luckily, not all browsers break in this way, and we can test for proper full support before applying Hyphenator.js.

function test_wordbreak(delimiter) { try { /* create a div container and a span within that * these have to be appended to document.body, otherwise some browsers can give false negative */ var div = document.createElement('div'), span = document.createElement('span'), divStyle = div.style, spanSize = 0, result = false, result1 = false, result2 = false; document.body.appendChild(div); div.appendChild(span); divStyle.cssText = 'position:absolute;top:0;left:0;overflow:visible;width:1.25em;'; /* get height of unwrapped text */ span.innerHTML = 'mm'; spanSize = span.offsetHeight; /* compare height w/ delimiter, to see if it wraps to new line */ span.innerHTML = 'm' + delimiter + 'm'; result = (span.offsetHeight > spanSize); /* results and cleanup */ div.removeChild(span); document.body.removeChild(div); return result; } catch(e) { return false; } }
Figure 2: Testing soft hyphens (first attempt)

The test_wordbreak() function above takes a string as an argument and uses that string as a delimiter between two characters. With this function we can test &shy; (or &#193;) and zero-width space delimiters to see if browsers acknowledge them and properly wrap text to a new line. We test for this by measuring the height of the container without the delimiter to get the height of a single line of text, then by measuring the height of the container with the delimiter. If the second measurement is larger, we can be reasonably sure that the text has been wrapped to a second line. It’s a little hacky, but it works.

This function works well in most browsers. However, some browsers that I’ve tested (specifically on BlackBerry devices, including the PlayBook) will recognize the soft hyphen and wrap the text properly, but won’t display the hyphen itself. For this reason, we need to modify the function above to also test for the width of the container.

function test_hyphens(delimiter, testWidth) { try { /* create a div container and a span within that * these have to be appended to document.body, otherwise some browsers can give false negative */ var div = document.createElement('div'), span = document.createElement('span'), divStyle = div.style, spanSize = 0, result = false, result1 = false, result2 = false; document.body.appendChild(div); div.appendChild(span); divStyle.cssText = 'position:absolute;top:0;left:0;overflow:visible;width:1.25em;'; /* get height of unwrapped text */ span.innerHTML = 'mm'; spanSize = span.offsetHeight; /* compare height w/ delimiter, to see if it wraps to new line */ span.innerHTML = 'm' + delimiter + 'm'; result1 = (span.offsetHeight > spanSize); /* if we're testing the width too (i.e. for soft-hyphen, not zws), * this is because tested Blackberry devices will wrap the text but not display the hyphen */ if (testWidth) { /* get width of wrapped, non-hyphenated text */ span.innerHTML = 'm<br />m'; spanSize = span.offsetWidth; /* compare width w/ wrapped w/ delimiter to see if hyphen is present */ span.innerHTML = 'm' + delimiter + 'm'; result2 = (span.offsetWidth > spanSize); } else { result2 = true; } /* results and cleanup */ if (result1 === true && result2 === true) { result = true; } div.removeChild(span); document.body.removeChild(div); return result; } catch(e) { return false; } }
Figure 3: Testing soft hyphens (second attempt)

As before, if the width of the container with the soft hyphen is larger than the width without, we can be reasonably sure there is an extra visible hyphen being displayed.

These tests tell us whether the browser recognizes and uses the soft hyphen properly, but not whether they break the find-on-page functionality. To test that, we’ll need another function that injects some text with a delimiter, and then searches for that text without the delimiter. If the text is found, we know that find-on-page is not broken; if not, it is broken.

function test_hyphens_find(delimiter) { try { /* create a dummy input for resetting selection location, and a div container * these have to be appended to document.body, otherwise some browsers can give false negative * div container gets the doubled testword, separated by the delimiter * Note: giving a width to div gives false positive in iOS Safari */ var dummy = document.createElement('input'), div = document.createElement('div'), testword = 'lebowski', result = false, textrange; document.body.appendChild(dummy); document.body.appendChild(div); div.innerHTML = testword + delimiter + testword; /* reset the selection to the dummy input element, i.e. BEFORE the div container * this conditional block based on http://stackoverflow.com/questions/499126/jquery-set-cursor-position-in-text-area */ if (dummy.setSelectionRange) { dummy.focus(); dummy.setSelectionRange(0,0); } else if (dummy.createTextRange) { textrange = dummy.createTextRange(); textrange.collapse(true); textrange.moveEnd('character', 0); textrange.moveStart('character', 0); textrange.select(); } /* try to find the doubled testword, without the delimiter */ if (window.find) { result = window.find(testword + testword); } else { try { textrange = self.document.body.createTextRange(); result = textrange.findText(testword + testword); } catch(e) { result = false; } } document.body.removeChild(div); document.body.removeChild(dummy); return result; } catch(e) { return false; } }
Figure 4: Testing find-on-page with soft hyphens

By combining the functions in Figures 3 and 4, we can get a pretty good idea if it’s safe to use Hyphenator.js in a browser.

Problems and Browser Support

Naturally, life for a web developer is never that easy.

First, there's the issue of Chrome's support for the hyphens CSS. Unfortunately, Chrome claims that it supports this hyphenation, but in actual fact no hyphenation occurs. This is a problem if we want to test for CSS support before applying Hyphenator.js.

The solution to this is even more hacky than the functions above, but it should work.

function test_hyphens_css() { try { /* create a div container and a span within that * these have to be appended to document.body, otherwise some browsers can give false negative */ var div = document.createElement('div'), span = document.createElement('span'), divStyle = div.style, spanHeight = 0, spanWidth = 0, result = false, result1 = false, result2 = false; document.body.appendChild(div); div.appendChild(span); span.innerHTML = 'Bacon ipsum dolor sit amet jerky velit in culpa hamburger et. Laborum dolor proident, enim dolore duis commodo et strip steak. Salami anim et, veniam consectetur dolore qui tenderloin jowl velit sirloin. Et ad culpa, fatback cillum jowl ball tip ham hock nulla short ribs pariatur aute. Pig pancetta ham bresaola, ut boudin nostrud commodo flank esse cow tongue culpa. Pork belly bresaola enim pig, ea consectetur nisi. Fugiat officia turkey, ea cow jowl pariatur ullamco proident do laborum velit sausage. Magna biltong sint tri-tip commodo sed bacon, esse proident aliquip. Ullamco ham sint fugiat, velit in enim sed mollit nulla cow ut adipisicing nostrud consectetur. Proident dolore beef ribs, laborum nostrud meatball ea laboris rump cupidatat labore culpa. Shankle minim beef, velit sint cupidatat fugiat tenderloin pig et ball tip. Ut cow fatback salami, bacon ball tip et in shank strip steak bresaola. In ut pork belly sed mollit tri-tip magna culpa veniam, short ribs qui in andouille ham consequat. Dolore bacon t-bone, velit short ribs enim strip steak nulla. Voluptate labore ut, biltong swine irure jerky. Cupidatat excepteur aliquip salami dolore. Ball tip strip steak in pork dolor. Ad in esse biltong. Dolore tenderloin exercitation ad pork loin t-bone, dolore in chicken ball tip qui pig. Ut culpa tongue, sint ribeye dolore ex shank voluptate hamburger. Jowl et tempor, boudin pork chop labore ham hock drumstick consectetur tri-tip elit swine meatball chicken ground round. Proident shankle mollit dolore. Shoulder ut duis t-bone quis reprehenderit. Meatloaf dolore minim strip steak, laboris ea aute bacon beef ribs elit shank in veniam drumstick qui. Ex laboris meatball cow tongue pork belly. Ea ball tip reprehenderit pig, sed fatback boudin dolore flank aliquip laboris eu quis. Beef ribs duis beef, cow corned beef adipisicing commodo nisi deserunt exercitation. Cillum dolor t-bone spare ribs, ham hock est sirloin. Brisket irure meatloaf in, boudin pork belly sirloin ball tip. Sirloin sint irure nisi nostrud aliqua. Nostrud nulla aute, enim officia culpa ham hock. Aliqua reprehenderit dolore sunt nostrud sausage, ea boudin pork loin ut t-bone ham tempor. Tri-tip et pancetta drumstick laborum. Ham hock magna do nostrud in proident. Ex ground round fatback, venison non ribeye in.'; /* get size of unhyphenated text */ divStyle.cssText = 'position:absolute;top:0;left:0;width:5em;text-align:justify;text-justification:newspaper;'; spanHeight = span.offsetHeight; spanWidth = span.offsetWidth; /* compare size with hyphenated text */ divStyle.cssText = 'position:absolute;top:0;left:0;width:5em;text-align:justify;text-justification:newspaper;-moz-hyphens:auto;-webkit-hyphens:auto;-o-hyphens:auto;-ms-hyphens:auto;hyphens:auto;'; result = (span.offsetHeight != spanHeight || span.offsetWidth != spanWidth); /* results and cleanup */ div.removeChild(span); document.body.removeChild(div); return result; return result; } catch(e) { return false; } }
Figure 5: Testing for CSS hyphens support

Basically, this throws a huge wad of text into an element and sees if the element changes size when hyphenation is applied. Like I said, hacky.

A separate problem exists for the &shy; and zero-width space tests: in most of my browser tests, these functions performed beautifully. The exception, though, is on Android browsers. I don’t have consistent access to any Android devices for extensive testing, but based on what I’ve heard and seen, Android browsers will return a false positive on the test_wordbreak_find() test. This seems to be because the find-on-page that JavaScript is using is different than the find-on-page that the user has access to: JavaScript will find the delimited text, the user will not.

This kind of false positive means that Hyphenator.js will be applied even though using it will break find-on-page. Options for dealing with this are unappealing:

  1. Accept that find-on-page is broken on these devices.
  2. Do browser sniffing in the test to make sure Android browsers don’t have Hyphenator.js
  3. Give up on the whole thing entirely.

Wrapping it all in Modernizr

Modernizr is amazing and should be part of every web dev’s toolkit. Not only does it have a great built-in battery of tests for feature support, it also allows us to add our own. We can use Modernizr’s addTest() API to get very robust support for hyphenation on the web, without breaking anything in older browsers.

element { /* as far as I know, these two are unsupported, but their inclusion won't hurt */ -o-hyphens: auto; -ms-hyphens: auto; -moz-hyphens: auto; -webkit-hyphens: auto; hyphens: auto; } (function() { function test_hyphens(delimiter, testWidth) { try { /* create a div container and a span within that * these have to be appended to document.body, otherwise some browsers can give false negative */ var div = document.createElement('div'), span = document.createElement('span'), divStyle = div.style, spanSize = 0, result = false, result1 = false, result2 = false; document.body.appendChild(div); div.appendChild(span); divStyle.cssText = 'position:absolute;top:0;left:0;overflow:visible;width:1.25em;'; /* get height of unwrapped text */ span.innerHTML = 'mm'; spanSize = span.offsetHeight; /* compare height w/ delimiter, to see if it wraps to new line */ span.innerHTML = 'm' + delimiter + 'm'; result1 = (span.offsetHeight > spanSize); /* if we're testing the width too (i.e. for soft-hyphen, not zws), * this is because tested Blackberry devices will wrap the text but not display the hyphen */ if (testWidth) { /* get width of wrapped, non-hyphenated text */ span.innerHTML = 'm<br />m'; spanSize = span.offsetWidth; /* compare width w/ wrapped w/ delimiter to see if hyphen is present */ span.innerHTML = 'm' + delimiter + 'm'; result2 = (span.offsetWidth > spanSize); } else { result2 = true; } /* results and cleanup */ if (result1 === true && result2 === true) { result = true; } div.removeChild(span); document.body.removeChild(div); return result; } catch(e) { return false; } } function test_hyphens_find(delimiter) { try { /* create a dummy input for resetting selection location, and a div container * these have to be appended to document.body, otherwise some browsers can give false negative * div container gets the doubled testword, separated by the delimiter * Note: giving a width to div gives false positive in iOS Safari */ var dummy = document.createElement('input'), div = document.createElement('div'), testword = 'lebowski', result = false, textrange; document.body.appendChild(dummy); document.body.appendChild(div); div.innerHTML = testword + delimiter + testword; /* reset the selection to the dummy input element, i.e. BEFORE the div container * this conditional block based on http://stackoverflow.com/questions/499126/jquery-set-cursor-position-in-text-area */ if (dummy.setSelectionRange) { dummy.focus(); dummy.setSelectionRange(0,0); } else if (dummy.createTextRange) { textrange = dummy.createTextRange(); textrange.collapse(true); textrange.moveEnd('character', 0); textrange.moveStart('character', 0); textrange.select(); } /* try to find the doubled testword, without the delimiter */ if (window.find) { result = window.find(testword + testword); } else { try { textrange = self.document.body.createTextRange(); result = textrange.findText(testword + testword); } catch(e) { result = false; } } document.body.removeChild(div); document.body.removeChild(dummy); window.scroll(0,0); return result; } catch(e) { return false; } } function test_hyphens_css() { try { /* create a div container and a span within that * these have to be appended to document.body, otherwise some browsers can give false negative */ var div = document.createElement('div'), span = document.createElement('span'), divStyle = div.style, spanHeight = 0, spanWidth = 0, result = false, result1 = false, result2 = false; document.body.appendChild(div); div.appendChild(span); span.innerHTML = 'Bacon ipsum dolor sit amet jerky velit in culpa hamburger et. Laborum dolor proident, enim dolore duis commodo et strip steak. Salami anim et, veniam consectetur dolore qui tenderloin jowl velit sirloin. Et ad culpa, fatback cillum jowl ball tip ham hock nulla short ribs pariatur aute. Pig pancetta ham bresaola, ut boudin nostrud commodo flank esse cow tongue culpa. Pork belly bresaola enim pig, ea consectetur nisi. Fugiat officia turkey, ea cow jowl pariatur ullamco proident do laborum velit sausage. Magna biltong sint tri-tip commodo sed bacon, esse proident aliquip. Ullamco ham sint fugiat, velit in enim sed mollit nulla cow ut adipisicing nostrud consectetur. Proident dolore beef ribs, laborum nostrud meatball ea laboris rump cupidatat labore culpa. Shankle minim beef, velit sint cupidatat fugiat tenderloin pig et ball tip. Ut cow fatback salami, bacon ball tip et in shank strip steak bresaola. In ut pork belly sed mollit tri-tip magna culpa veniam, short ribs qui in andouille ham consequat. Dolore bacon t-bone, velit short ribs enim strip steak nulla. Voluptate labore ut, biltong swine irure jerky. Cupidatat excepteur aliquip salami dolore. Ball tip strip steak in pork dolor. Ad in esse biltong. Dolore tenderloin exercitation ad pork loin t-bone, dolore in chicken ball tip qui pig. Ut culpa tongue, sint ribeye dolore ex shank voluptate hamburger. Jowl et tempor, boudin pork chop labore ham hock drumstick consectetur tri-tip elit swine meatball chicken ground round. Proident shankle mollit dolore. Shoulder ut duis t-bone quis reprehenderit. Meatloaf dolore minim strip steak, laboris ea aute bacon beef ribs elit shank in veniam drumstick qui. Ex laboris meatball cow tongue pork belly. Ea ball tip reprehenderit pig, sed fatback boudin dolore flank aliquip laboris eu quis. Beef ribs duis beef, cow corned beef adipisicing commodo nisi deserunt exercitation. Cillum dolor t-bone spare ribs, ham hock est sirloin. Brisket irure meatloaf in, boudin pork belly sirloin ball tip. Sirloin sint irure nisi nostrud aliqua. Nostrud nulla aute, enim officia culpa ham hock. Aliqua reprehenderit dolore sunt nostrud sausage, ea boudin pork loin ut t-bone ham tempor. Tri-tip et pancetta drumstick laborum. Ham hock magna do nostrud in proident. Ex ground round fatback, venison non ribeye in.'; /* get size of unhyphenated text */ divStyle.cssText = 'position:absolute;top:0;left:0;width:5em;text-align:justify;text-justification:newspaper;'; spanHeight = span.offsetHeight; spanWidth = span.offsetWidth; /* compare size with hyphenated text */ divStyle.cssText = 'position:absolute;top:0;left:0;width:5em;text-align:justify;text-justification:newspaper;-moz-hyphens:auto;-webkit-hyphens:auto;-o-hyphens:auto;-ms-hyphens:auto;hyphens:auto;'; result = (span.offsetHeight != spanHeight || span.offsetWidth != spanWidth); /* results and cleanup */ div.removeChild(span); document.body.removeChild(div); return result; return result; } catch(e) { return false; } } /* check if browser claims support for CSS hyphens */ Modernizr.addTest("csshyphens", function() { return Modernizr.testAllProps('hyphens'); }); /* check if CSS hyphens actually work */ Modernizr.addTest("workingcsshyphens", function() { try { return test_hyphens_css(); } catch(e) { return false; } } /* check if soft hyphens and zws are displayed properly */ Modernizr.addTest("softhyphens", function() { try { return test_hyphens('&#173;', true) && test_wordbreak('&#8203;', false); // use numeric entity instead of ­ in case it's XHTML } catch(e) { return false; } }); /* check if find-on-page works with soft hyphens and zws */ Modernizr.addTest("softhyphensfind", function() { try { return test_hyphens_find('&#173;') && test_wordbreak_find('&#8203;'); } catch(e) { return false; } }); Modernizr.load({ test: (!Modernizr.csshyphens || !Modernizr.workingcsshyphens) && Modernizr.softhyphens && Modernizr.softhyphensfind, yep : 'hyphenator.js' }); })();
Figure 6: Final suite of tests

These tests will check for both CSS hyphenation support and Hyphenator.js soft hyphen/zero-width space support. The results of these tests will allow us to dynamically apply different styles and JS libraries based on what the user’s browser supports.

  1. If CSS hyphenation is supported, it will be applied; browsers that don’t recognize the CSS hyphenation rules will simply ignore them.
  2. If CSS hyphenation is not supported, but soft hyphens and the zero width space are, we’ll load and apply Hyphenator.js.
  3. If neither are supported, the page remains unhyphenated but functional.

Your turn!

If you’d like to test this out for yourself in your own browser, feel free to check out this demo page and let me know your results, either in a comment, by email or via Twitter.

This is pretty basic right now, and is more of a proof-of-concept. I definitely welcome feedback and improvements. I’ve forked Modernizr on GitHub and have added this as a feature-detect, so feel free to fork it yourself and make it better! (Especially if you have a fix for the Android problem!) [Note: my feature test has now been pulled into the main Modernizr repo, so you can also mess around with it there].

Updates

  1. Updated to mention Firefox 6 release
  2. Updated to fix a syntax error in Figure 6
  3. Updated to add window.scroll(0,0) in Figure 6 and update GitHub note
  4. Updated to fix soft hyphen and ZWS characters that weren't appearing correctly in the code