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")))