Hanging Punctuation

You may or may not have noticed that all my quote marks are outdented if they fall at the beginning of a line.

You may or may not think I’m crazy for not only caring, but writing a 50 line script to do it.

The typography nerds call this hanging punctuation.

The heart of any such script is, of course, a regular expression. I’m sorry.

The expression gets any series of quote marks (/[`'"“”‘’«»‹›]+/) that either begin at the start of a word (/^|\s/), or at the end of a word (/$|\s/). This successfully grabs all the quote marks and ignores all the apostrophes.

quote = /(?:(^|\s)([`'"“”‘’«»‹›]+)|([`'"“”‘’«»‹›]+)($|\s))/g

The next most important part of the process is a way to grab all the text nodes in a document. You’d think jQuery would just give you this, but it’s only 5 lines of coffeescript to roll our own.

This recursively steps through nodes, building up a jQuery object of just the text (nodeType == 3), until we have all the contiguous strings in the document and none of the HTML. Booya.

$.fn.textNodes = ->
  textNodes = $(this).contents().filter -> (this? && this.nodeType == 3)
  if $(this).children()[0]?

Next we wrap the quotes in something we can grab and manipulate. We don’t want to affect anything inside a code element, or a table, and we want this script to be safe to run repeatedly without wrapping the already-wrapped.

$.fn.wrapQuotes = (className = 'hq', exclude = 'code,table') ->
  replacement = "$1<span class=\"#{className}\">$2$3</span>$4"
  exclude = "#{exclude},.#{className}"
  $(this).textNodes().each ->
    unless $(this).closest(exclude)[0]?
      $(this).replaceWith( $(this).text().replace(quote, replacement) )

Now that all our quote marks are surrounded by <span class="hq"></span> we can find out if they’re at the edge of their margin, and then we can outdent them.

Before that we’ll need yet another utility function, this to determine which edge/margin we want. This returns 'left', or 'right' based on the text alignment, and/or the language direction1.

$.fn.leadingEdge = ->
  align = $(this).css('textAlign')
  dir = $(this).css('direction')
  if align == 'left'  ||
     align == 'start' && dir == 'ltr' ||
     align == 'end'   && dir == 'rtl'

Because we’re going to outdent these quote marks by absolutely positioning them, we need a way of applying a positioning context to the parent without overriding whatever styling they had. We set position: only if something’s not already set.

$.fn.relativise = ->
  $(this).each ->
    if $(this).css('position') == 'static'

With all those pieces in play, we can position the various quotes marks as necessary. First we find the elements we want and remove any styling they currently have. We get all the value we need using the aforementioned utility functions.

$.fn.hangQuotes = (className = 'hq') ->
  $(this).find(".#{className}").css(position:'',left:'',right:'').each ->

    parent = $(this).parent().relativise()
    edge = $(this).leadingEdge()
    parentPadding = parseFloat(parent.css("padding-#{edge}"), 10) # assume px
    textWidth = $(this).width()
    # ...

Then finally do the actual positioning. If the quote is against the edge, we first take it out of the document flow, and then check it’s still against the edge in case the reflow that we just triggered wrapped it to the previous line. We outdent it by its width.

    # ...
    if textWidth && $(this).position()[edge] == parentPadding
      if $(this).position()[edge] == parentPadding
        outdent = textWidth - parentPadding
        $(this).css(edge, "#{-outdent}px")

All & that remains is to assign & some events, so these scripts run when they need to. We would also need to run hangQuotes() after fonts have loaded, and wrapQuotes() after ajax has ajaxed.

$ ->
$(window).resize ->

And we’re done! Lovely typography at the expense of a small handful of javascripts and brains.


  1. All the text in this blog is left-aligned, but that won’t necessarily always be the case, it’s a small robustness.