diff --git a/.gitignore b/.gitignore
index c146d02c..fec4707f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,7 @@ example/tags/
example/2*
example/A-Non-Post-Scribble-Page*
example/img/posts/
+example/img/resized/
# Emacs / DrRacket backups & auto save files
*~
diff --git a/example/.frogrc b/example/.frogrc
index 98a12f77..cea22570 100644
--- a/example/.frogrc
+++ b/example/.frogrc
@@ -1,4 +1,6 @@
-# Required: Should NOT end in trailing slash.
+# -*- conf -*-
+
+# Required: Should NOT end in trailing slash.
scheme/host = http://www.example.com
# A path prepended to URIs, including those specified here in .frogrc
@@ -117,3 +119,20 @@ python-executable = python
pygments-linenos? = true
## CSS class for the wrapping
tag (default: 'highlight').
pygments-cssclass = source
+
+# Serve responsive images.
+#
+# Make use of the img srcset attribute to serve images inside elements
+# of class "figure" (such as image referenced from Markdown) at three
+# different sizes. Depends on having ImageMagick installed.
+responsive-images? = true
+
+# Subdirectory of where to put resized images. Defaults to "resized".
+# The directory will be created but the parent "img" directory must exist.
+#image-output-dir = resized
+
+# Value of the img "sizes" attribute.
+# Defaults to "(max-width: px) 100vw, px"
+# If your blog's main column is narrower than the page width on wide
+# clients you may want to have something like:
+#image-sizes-attr = (max-width: ) 100vw,
diff --git a/example/_src/posts/2013-06-19-a-scribble-post.scrbl b/example/_src/posts/2013-06-19-a-scribble-post.scrbl
index 43ef2eff..0b4dab97 100644
--- a/example/_src/posts/2013-06-19-a-scribble-post.scrbl
+++ b/example/_src/posts/2013-06-19-a-scribble-post.scrbl
@@ -110,3 +110,9 @@ function foo() {
return 7;
}
}
+
+@subsection[#:style 'unnumbered]{B SubSection}
+
+A responsive big black image:
+
+@image["img/800px-image.gif" #:style "img-responsive"]
diff --git a/example/_src/posts/2016-08-12-a-blog-post-featuring-a-big-image.md b/example/_src/posts/2016-08-12-a-blog-post-featuring-a-big-image.md
new file mode 100644
index 00000000..522c446c
--- /dev/null
+++ b/example/_src/posts/2016-08-12-a-blog-post-featuring-a-big-image.md
@@ -0,0 +1,7 @@
+ Title: A blog post featuring a big image
+ Date: 2016-08-12T02:43:56
+ Tags: images, responsive
+
+The below `img` tag should come with _srcset_ and _sizes_ definitions:
+
+![Image title](/img/800px-image.gif)
diff --git a/example/img/1300px-image.gif b/example/img/1300px-image.gif
new file mode 100644
index 00000000..0be15ae6
Binary files /dev/null and b/example/img/1300px-image.gif differ
diff --git a/example/img/600px-image.gif b/example/img/600px-image.gif
new file mode 100644
index 00000000..4666a934
Binary files /dev/null and b/example/img/600px-image.gif differ
diff --git a/example/img/800px-image.gif b/example/img/800px-image.gif
new file mode 100644
index 00000000..d0941388
Binary files /dev/null and b/example/img/800px-image.gif differ
diff --git a/frog/enhance-body.rkt b/frog/enhance-body.rkt
index 931ba569..260e2c5e 100644
--- a/frog/enhance-body.rkt
+++ b/frog/enhance-body.rkt
@@ -13,7 +13,10 @@
"html.rkt"
"params.rkt"
"pygments.rkt"
- "xexpr-map.rkt")
+ "xexpr-map.rkt"
+ "verbosity.rkt"
+ "paths.rkt"
+ "responsive-images.rkt")
(provide enhance-body)
@@ -21,10 +24,150 @@
(define (enhance-body xs)
(~> xs
+ responsive-images
syntax-highlight
add-racket-doc-links
auto-embed-tweets))
+(define responsive-images
+ (let ([magick-notice-displayed? #f])
+ (λ (xs)
+ (define (remote-host url)
+ (url-host (string->url url)))
+ (define (do-it xs)
+ (for/list ([x xs])
+ (match x
+ [`(div ([class ,classes])
+ (img ,(list-no-order `[src ,url] attrs ...))
+ ,content ...)
+ #:when (and (regexp-match #px"\\bfigure\\b" classes)
+ (not (remote-host url)))
+ (let ([sizes-attr (assq 'sizes attrs)])
+ `(div ([class ,classes])
+ (img ([class "img-responsive"] ; Add Bootstrap class
+ ,@(make-responsive url (cond [sizes-attr => second]
+ [#t #f]))
+ ,@(if sizes-attr
+ (remove sizes-attr attrs)
+ attrs)))
+ ,@content))
+ ]
+ ;; xexpr-map?
+ [`(p () (img ,(list-no-order `[src ,url] `[class ,classes] attrs ...)))
+ #:when (and (regexp-match #px"\\bimg-responsive\\b" classes)
+ (not (remote-host url)))
+ `(p () (img ([class ,classes]
+ ,@(make-responsive url #f) ; TODO honor custom sizes?
+ ,@attrs)))]
+ [x x])))
+ (cond [(current-responsive-images?)
+ (if magick-available?
+ (do-it xs)
+ (begin
+ (unless magick-notice-displayed?
+ (prn1 "ImageMagick not found. Omitting img srcset attributes.")
+ (set! magick-notice-displayed? #t))
+ xs))]
+ [else xs]))))
+
+
+(module+ test
+ (parameterize ([top example]
+ [current-responsive-images? #t]
+ [current-image-output-dir "resized"]
+ [current-image-sizes-attr #f]
+ [current-image-sizes '(320 600 1200)]
+ [current-image-default-size 600]
+ [current-verbosity 0])
+ (test-equal? "Remote images"
+ (responsive-images
+ '((div ((class "figure")) (img ((src "//somehost.com/img/file.jpg"))))))
+ ;; Don't resize remote images. Or should we fetch it and resize it?
+ '((div ((class "figure")) (img ((src "//somehost.com/img/file.jpg"))))))
+ (when magick-available?
+ (test-equal? "Element-specific custom sizes attribute"
+ (responsive-images
+ '((div ([class "figure"])
+ (img ([src "/img/1x1.gif"]
+ [sizes "some-custom-size-spec"])))))
+ '((div ((class "figure"))
+ (img ([class "img-responsive"]
+ [src "/img/1x1.gif"]
+ [srcset "/img/1x1.gif 2w"]
+ [sizes "some-custom-size-spec"])))))
+ (test-equal? "Img with img-responsive class inside p tag"
+ (responsive-images
+ '((p () (img ([src "/img/1x1.gif"]
+ [alt ""]
+ [class "img-responsive among-others"]
+ [foo-attr "bar"])))))
+ '((p () (img ([class "img-responsive among-others"]
+ [src "/img/1x1.gif"]
+ [srcset "/img/1x1.gif 2w"]
+ [sizes "(max-width: 2px) 100vw, 2px"]
+ [alt ""]
+ [foo-attr "bar"])))))
+ (test-equal? "Image bigger than maximum size"
+ (responsive-images
+ '((div ([class "figure pull-right"])
+ (img ([src "/img/1300px-image.gif"] (alt "")))
+ (p ([class "caption"]) "some text"))))
+ `((div ((class "figure pull-right"))
+ (img ([class "img-responsive"]
+ [src "/img/resized/600/1300px-image.gif"]
+ [srcset
+ ,(string-join
+ (for/list ([s (current-image-sizes)])
+ (format "/img/resized/~a/1300px-image.gif ~aw" s s))
+ ", ")]
+ [sizes "(max-width: 1300px) 100vw, 1300px"]
+ (alt "")))
+ (p ((class "caption")) "some text"))))
+ (test-equal? "Image smaller than biggest size but bigger than smallest size"
+ (responsive-images
+ '((div ((class "figure"))
+ (img ((src "/img/800px-image.gif") (alt "")))
+ (p ((class "caption")) "some text"))))
+ `((div ((class "figure"))
+ (img ([class "img-responsive"]
+ (src ,(format "/img/resized/~a/800px-image.gif"
+ (current-image-default-size)))
+ (srcset
+ ,(string-append
+ (string-join
+ (for/list ([s '(320 600)])
+ (format "/img/resized/~a/800px-image.gif ~aw" s s))
+ ", ")
+ ", /img/800px-image.gif 800w"))
+ (sizes "(max-width: 800px) 100vw, 800px")
+ (alt "")))
+ (p ((class "caption")) "some text"))))
+ (test-equal? "Image equal to a one of the sizes specified"
+ (responsive-images
+ '((div ((class "figure"))
+ (img ((src "/img/600px-image.gif") (alt "")))
+ (p ((class "caption")) "some text"))))
+ '((div ((class "figure"))
+ (img ([class "img-responsive"]
+ (src "/img/600px-image.gif")
+ (srcset "/img/resized/320/600px-image.gif 320w, /img/600px-image.gif 600w")
+ (sizes "(max-width: 600px) 100vw, 600px")
+ (alt "")))
+ (p ((class "caption")) "some text"))))
+ (test-equal? "Image smaller than smallest size"
+ (responsive-images
+ '((div ((class "figure"))
+ (img ((src "/img/1x1.gif") (alt ""))) ; Tiny image
+ (p ((class "caption")) "some text"))))
+ '((div ((class "figure"))
+ (img ([class "img-responsive"]
+ (src "/img/1x1.gif")
+ (srcset "/img/1x1.gif 2w")
+ (sizes "(max-width: 2px) 100vw, 2px")
+ (alt "")))
+ (p ((class "caption")) "some text"))))
+ (clean-resized-images))))
+
(define (syntax-highlight xs)
(for/list ([x xs])
(match x
@@ -131,6 +274,7 @@
"&hide_thread=true"))))
(define js (call/input-url oembed-url get-pure-port read-json))
(define html ('html js))
+
(cond [html (~>> (with-input-from-string html read-html-as-xexprs)
(append '(div ([class "embed-tweet"]))))]
[else x])]
diff --git a/frog/frog.rkt b/frog/frog.rkt
index 575d6941..aaf4f5a4 100644
--- a/frog/frog.rkt
+++ b/frog/frog.rkt
@@ -25,7 +25,8 @@
"tags.rkt"
"util.rkt"
"verbosity.rkt"
- "watch-dir.rkt")
+ "watch-dir.rkt"
+ "responsive-images.rkt")
(provide serve)
(module+ test
@@ -64,7 +65,12 @@
[output-dir "."]
[python-executable "python"]
[pygments-linenos? #t]
- [pygments-cssclass "source"])
+ [pygments-cssclass "source"]
+ [responsive-images? #f]
+ [image-output-dir "resized"]
+ [image-sizes-attr #f]
+ [image-sizes '(320 768 1024)]
+ [image-default-size 768])
(define watch? #f)
(define port 3000)
(define root
@@ -351,7 +357,8 @@
(clean-post-output-files)
(clean-non-post-output-files)
(clean-tag-output-files)
- (clean-serialized-posts))
+ (clean-serialized-posts)
+ (clean-resized-images))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/frog/params.rkt b/frog/params.rkt
index 8dcb0b8d..4ac63648 100644
--- a/frog/params.rkt
+++ b/frog/params.rkt
@@ -40,3 +40,8 @@
(define current-python-executable (make-parameter "python"))
(define current-pygments-linenos? (make-parameter #t))
(define current-pygments-cssclass (make-parameter "source"))
+(define current-responsive-images? (make-parameter #f))
+(define current-image-output-dir (make-parameter "resized"))
+(define current-image-sizes-attr (make-parameter #f))
+(define current-image-sizes (make-parameter '(320 768 1024)))
+(define current-image-default-size (make-parameter 768))
diff --git a/frog/responsive-images.rkt b/frog/responsive-images.rkt
new file mode 100644
index 00000000..b461a8e2
--- /dev/null
+++ b/frog/responsive-images.rkt
@@ -0,0 +1,133 @@
+#lang racket/base
+
+(require net/url
+ racket/contract/base
+ racket/contract/region
+ racket/file
+ racket/function
+ racket/list
+ racket/path
+ racket/port
+ racket/system
+ racket/string
+ rackjure/threading
+ "params.rkt"
+ "util.rkt"
+ "verbosity.rkt"
+ "paths.rkt")
+
+(provide make-responsive clean-resized-images magick-available?)
+
+;; Depend on ImageMagick
+(define identify (find-executable-path "identify"))
+(define mogrify (find-executable-path "mogrify"))
+
+(define magick-available? (and identify mogrify))
+
+(module+ test
+ (require rackunit))
+
+(define (image-width path)
+ (~> (with-output-to-string
+ (λ ()
+ (system* identify "-format" "%w" path)))
+ string-trim
+ string->number))
+
+(module+ test
+ (when magick-available?
+ (parameterize ([top example])
+ (check-eq? (image-width (build-path (www/img-path) "800px-image.gif")) 800))))
+
+(define/contract (resize-image in new-width out-path)
+ (path? number? path? . -> . boolean?)
+ (prn1 "Shrinking ~a to ~a pixels... " (abs->rel/www in) new-width)
+ ;; Imagemagick options from
+ ;; https://www.smashingmagazine.com/2015/06/efficient-image-resizing-with-imagemagick/
+ (apply system* mogrify
+ `("-filter" "Triangle" "-define" "filter:support=2"
+ "-unsharp" "0.25x0.08+8.3+0.045" "-dither" "None" "-posterize" "136"
+ "-quality" "82" "-define" "jpeg:fancy-upsampling=off"
+ "-define" "png:compression-filter=5" "-define" "png:compression-level=9"
+ "-define" "png:compression-strategy=1" "-define" "png:exclude-chunk=all"
+ "-interlace" "none" "-colorspace" "sRGB"
+ "-thumbnail" ,(number->string new-width)
+ "-path" ,out-path ,in)))
+
+(module+ test
+ (when magick-available?
+ (parameterize ([top example]
+ [current-verbosity 0])
+ (define tmp (find-system-path 'temp-dir))
+ (define output (build-path tmp "600px-image.gif"))
+ (test-eq? "resize"
+ (begin
+ (resize-image (build-path (www/img-path) "600px-image.gif") 10 tmp)
+ (image-width output))
+ 10)
+ (delete-file* output))))
+
+(define/contract (get-images image-path)
+ (path? . -> . (values pair? (listof pair?)))
+ (define resized-dir (build-path* (www/img-path) (current-image-output-dir)))
+ (unless (directory-exists? resized-dir)
+ (make-directory resized-dir))
+ (let* ([orig-size (image-width image-path)]
+ [sizes (filter ((curry >) orig-size) (current-image-sizes))])
+ (values (cons image-path orig-size)
+ (append (for/list ([width sizes])
+ (define output-dir (build-path* resized-dir
+ (number->string width)))
+ (define output (build-path output-dir
+ (file-name-from-path image-path)))
+ (unless (directory-exists? output-dir)
+ (make-directory output-dir))
+ (unless (and (file-exists? output)
+ (< (file-or-directory-modify-seconds image-path)
+ (file-or-directory-modify-seconds output)))
+ ;; TODO Spawn asynchronously to enable utilizing more cores
+ (resize-image image-path width output-dir))
+ (cons output width))
+ (if (< (length sizes) (length (current-image-sizes)))
+ (list (cons image-path orig-size))
+ '())))))
+
+(define default-image-idx
+ (for/or ([v (current-image-sizes)]
+ [ix (in-naturals)])
+ (and (= v (current-image-default-size)) ix)))
+
+(define/contract (make-responsive path sizes)
+ (path-string? (or/c string? #f) . -> . (listof pair?))
+ (define image-path (build-path (www-path) (path->relative-path path)))
+ (define-values (orig srcset) (get-images image-path))
+ (define src (abs->rel/www (car (if (> (length srcset) default-image-idx)
+ (list-ref srcset default-image-idx)
+ orig))))
+ (define srcset-string
+ (string-join
+ (for/list ([srcdef srcset])
+ (format "~a ~aw" (~> (car srcdef)
+ abs->rel/www string->path
+ uri-encode-path path->string)
+ (cdr srcdef)))
+ ", "))
+ `([src ,src]
+ [srcset ,srcset-string]
+ ,(let ((orig-width (cdr orig)))
+ `[sizes ,(or sizes
+ (current-image-sizes-attr)
+ (format "(max-width: ~apx) 100vw, ~apx"
+ orig-width orig-width))])))
+
+(define/contract (clean-resized-images)
+ (-> any)
+ (let ([out-dir (build-path* (www/img-path) (current-image-output-dir))])
+ (when (directory-exists? out-dir)
+ (fold-files (λ (path type v)
+ (when (eq? type 'file)
+ (delete-file path)
+ (prn2 "Deleted ~a" (abs->rel/www path))))
+ '() out-dir #f)
+ (for-each delete-directory (directory-list out-dir #:build? #t))
+ (delete-directory out-dir))))
diff --git a/frog/util.rkt b/frog/util.rkt
index 79b32461..3874b02e 100644
--- a/frog/util.rkt
+++ b/frog/util.rkt
@@ -4,8 +4,9 @@
racket/function
racket/pretty
rackjure/threading
+ (only-in net/uri-codec uri-path-segment-encode)
(only-in markdown display-xexpr)
- "verbosity.rkt")
+ "verbosity.rkt")
(provide (all-defined-out))
@@ -68,3 +69,13 @@
(check-equal? (our-encode "Here's a question--how many hyphens???")
"Here-s-a-question-how-many-hyphens"))
+;; URI encode path to handle spaces and non-ascii characters
+(define (uri-encode-path path)
+ ;; (absolute-path? . -> . path?)
+ (let ([ps (for/list ([ps (explode-path path)])
+ (uri-path-segment-encode (path->string ps)))])
+ (apply build-path "/" (cdr ps))))
+
+(module+ test
+ (check-equal? (uri-encode-path (string->path "/dir/other dir/file name.ext"))
+ (string->path "/dir/other%20dir/file%20name.ext")))