Skip to content

Commit aa513ff

Browse files
zampinomk
andauthored
Suppport local Files in Markdown Images (#419)
With these changes, image sources in markdown comments are transformed according to: * `serve!`: the file path is served as-is by clerk webserver * `(build! {:bundle true})` images are inlined as base64 encoded data * `(build! {:bundle false})`: images are content addressed and stored in the `_data` folder In addition, we now allow `clerk/image` to be called against local paths. Closes #223. Co-authored-by: Martin Kavalar <[email protected]>
1 parent 677d954 commit aa513ff

File tree

6 files changed

+116
-58
lines changed

6 files changed

+116
-58
lines changed

.gitattributes

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
;; always force unix line endings to make hashing consistent
2-
* text eol=lf
2+
* text eol=lf
3+
*.png binary

notebooks/viewers/image.clj

+18-5
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
;; # 🏞 Image Viewer
22
(ns image
3-
(:require [nextjournal.clerk :as clerk])
4-
(:import (java.net URL)
5-
(javax.imageio ImageIO)))
3+
(:require [babashka.fs :as fs]
4+
[nextjournal.clerk :as clerk])
5+
(:import (javax.imageio ImageIO)
6+
(java.awt.image BufferedImage)
7+
(java.net URL)))
68

79
;; Clerk now comes with a default viewer for `java.awt.image.BufferedImage`. It looks at the dimensions of the image, and tries to do the right thing. For an image larger than 900px wide with an aspect ratio larger 2, it uses full width.
810
(ImageIO/read (URL. "https://images.unsplash.com/photo-1532879311112-62b7188d28ce?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8"))
@@ -13,9 +15,20 @@
1315
(clerk/image "https://images.freeimages.com/images/large-previews/773/koldalen-4-1384902.jpg")
1416

1517
;; Smaller images are centered and shown using their intrinsic dimensions. Here, we're using `clerk/figure`:
16-
1718
(clerk/image "https://nextjournal.com/data/QmeyvaR3Q5XSwe14ZS6D5WBQGg1zaBaeG3SeyyuUURE2pq?filename=thermos.gif&content-type=image/gif")
1819

1920
;; Layout options are also available. For example, `{::clerk/width :full}` renders the image in full width.
20-
2121
(clerk/image {::clerk/width :full} "https://images.freeimages.com/images/large-previews/773/koldalen-4-1384902.jpg")
22+
23+
;; We also support local files for `clerk/image`:
24+
(clerk/image "trees.png")
25+
26+
;; ## Markdown Notation
27+
28+
;; Given a local file named `trees.png` we can use the markdown image syntax `![alt text](trees.png)` to get:
29+
;;
30+
;; ![Drawing of trees in black and white](trees.png)
31+
;;
32+
;; This also works for https urls, of course.
33+
;;
34+
;; Images occuring inside a paragraph are placed inline like this ![Clerk CI Status](https://github.com/nextjournal/clerk/actions/workflows/main.yml/badge.svg) badge.

src/nextjournal/clerk/viewer.cljc

+65-32
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
(java.lang Throwable)
2424
(java.awt.image BufferedImage)
2525
(java.util Base64)
26-
(java.net URL)
26+
(java.net URI URL)
2727
(java.nio.file Files StandardOpenOption)
2828
(javax.imageio ImageIO))))
2929

@@ -316,11 +316,11 @@
316316
(-> (with-viewer {:name `html-viewer- :render-fn 'identity} wrapped-value)
317317
mark-presented
318318
(update :nextjournal/value
319-
(fn [{:as node :keys [text content]}]
319+
(fn [{:as node :keys [text content] ::keys [doc]}]
320320
(into (cond-> markup (fn? markup) (apply [node]))
321321
(cond text [text]
322-
content (mapv #(-> (with-md-viewer %)
323-
(assoc :nextjournal/viewers viewers)
322+
content (mapv #(-> (ensure-wrapped-with-viewers viewers (assoc % ::doc doc))
323+
(with-md-viewer)
324324
(apply-viewers)
325325
(as-> w
326326
(if (= `html-viewer- (:name (->viewer w)))
@@ -397,14 +397,13 @@
397397
result))
398398

399399
#?(:clj
400-
(defn base64-encode-value [{:as result :nextjournal/keys [content-type]}]
401-
(update result :nextjournal/value (fn [data] (str "data:" content-type ";base64,"
402-
(.encodeToString (Base64/getEncoder) data))))))
400+
(defn data-uri-base64-encode [x content-type]
401+
(str "data:" content-type ";base64," (.encodeToString (Base64/getEncoder) x))))
403402

404403
#?(:clj
405404
(defn store+get-cas-url! [{:keys [out-path ext]} content]
406-
(assert out-path) (assert ext)
407-
(let [cas-url (str "_data/" (analyzer/sha2-base58 content) "." ext)
405+
(assert out-path)
406+
(let [cas-url (str "_data/" (analyzer/sha2-base58 content) (when ext ".") ext)
408407
cas-path (fs/path out-path cas-url)]
409408
(fs/create-dirs (fs/parent cas-path))
410409
(when-not (fs/exists? cas-path)
@@ -433,10 +432,10 @@
433432

434433
#?(:clj
435434
(defn process-blobs [{:as doc+blob-opts :keys [blob-mode blob-id]} presented-result]
436-
(w/postwalk #(if (get % :nextjournal/content-type)
435+
(w/postwalk #(if-some [content-type (get % :nextjournal/content-type)]
437436
(case blob-mode
438437
:lazy-load (assoc % :nextjournal/value {:blob-id blob-id :path (:path %)})
439-
:inline (base64-encode-value %)
438+
:inline (update % :nextjournal/value data-uri-base64-encode content-type)
440439
:file (maybe-store-result-as-file doc+blob-opts %))
441440
%)
442441
presented-result)))
@@ -463,7 +462,7 @@
463462

464463
(declare result-viewer)
465464

466-
(defn transform-result [{:as _cell :keys [doc result form]}]
465+
(defn transform-result [{:as _cell :keys [result form] ::keys [doc]}]
467466
(let [{:keys [auto-expand-results? inline-results? bundle?]} doc
468467
{:nextjournal/keys [value blob-id viewers]} result
469468
blob-mode (cond
@@ -507,18 +506,54 @@
507506
#_(->display {:result {:nextjournal.clerk/visibility {:code :fold :result :show}}})
508507
#_(->display {:result {:nextjournal.clerk/visibility {:code :fold :result :hide}}})
509508

510-
(defn process-sidenotes [{:as doc :keys [footnotes]} cell-doc]
509+
(defn process-sidenotes [cell-doc {:keys [footnotes]}]
511510
(if (seq footnotes)
512511
(md.parser/insert-sidenote-containers (assoc cell-doc :footnotes footnotes))
513512
cell-doc))
514513

514+
(defn process-image-source [src {:as doc :keys [file bundle?]}]
515+
#?(:cljs src
516+
:clj (cond
517+
(not (fs/exists? src)) src
518+
(false? bundle?) (str (relative-root-prefix-from (map-index doc file))
519+
(store+get-cas-url! (assoc doc :ext (fs/extension src))
520+
(fs/read-all-bytes src)))
521+
bundle? (data-uri-base64-encode (fs/read-all-bytes src) (Files/probeContentType (fs/path src)))
522+
:else (str "_fs/" src))))
523+
524+
#?(:clj
525+
(defn read-image [image-or-url]
526+
(ImageIO/read
527+
(if (string? image-or-url)
528+
(URL. (cond->> image-or-url (not (.getScheme (URI. image-or-url))) (str "file:")))
529+
image-or-url))))
530+
531+
#?(:clj
532+
(defn image-width [image]
533+
(let [w (.getWidth image) h (.getHeight image) r (float (/ w h))]
534+
(if (and (< 2 r) (< 900 w)) :full :wide))))
535+
536+
(defn md-image->viewer [doc {:keys [attrs]}]
537+
(with-viewer `html-viewer
538+
#?(:clj {:nextjournal.clerk/width (try (image-width (read-image (:src attrs)))
539+
(catch Throwable _ :prose))})
540+
[:div.flex.flex-col.items-center.not-prose.mb-4
541+
[:img (update attrs :src process-image-source doc)]]))
542+
515543
(defn with-block-viewer [doc {:as cell :keys [type]}]
516544
(case type
517-
:markdown [(with-viewer `markdown-viewer (process-sidenotes doc (:doc cell)))]
545+
:markdown (let [{:keys [content]} (:doc cell)]
546+
(mapcat (fn [fragment]
547+
(if (= :image (:type (first fragment)))
548+
(map (partial md-image->viewer doc) fragment)
549+
[(with-viewer `markdown-viewer (process-sidenotes {:type :doc
550+
:content (vec fragment)
551+
::doc doc} doc))]))
552+
(partition-by (comp #{:image} :type) content)))
553+
518554
:code (let [cell (update cell :result apply-viewer-unwrapping-var-from-def)
519555
{:as display-opts :keys [code? result?]} (->display cell)
520556
eval? (-> cell :result :nextjournal/value (get-safe :nextjournal/value) viewer-eval?)]
521-
;; TODO: use vars instead of names
522557
(cond-> []
523558
code?
524559
(conj (with-viewer `code-block-viewer {:nextjournal.clerk/opts (select-keys cell [:loc])}
@@ -528,7 +563,7 @@
528563
(conj (with-viewer (if result?
529564
(:name result-viewer)
530565
(assoc result-viewer :render-fn '(fn [_] [:<>])))
531-
(assoc cell :doc doc)))))))
566+
(assoc cell ::doc doc)))))))
532567

533568
#_(nextjournal.clerk.view/doc->viewer @nextjournal.clerk.webserver/!doc)
534569

@@ -614,13 +649,14 @@
614649
(def markdown-viewers
615650
[{:name :nextjournal.markdown/doc
616651
:transform-fn (into-markup [:div.markdown-viewer])}
617-
618-
;; blocks
619652
{:name :nextjournal.markdown/heading
620653
:transform-fn (into-markup
621654
(fn [{:keys [attrs heading-level]}]
622655
[(str "h" heading-level) attrs]))}
623-
{:name :nextjournal.markdown/image :transform-fn #(with-viewer `html-viewer [:img.inline (-> % ->value :attrs)])}
656+
{:name :nextjournal.markdown/image
657+
:transform-fn (fn [{node :nextjournal/value}]
658+
(with-viewer `html-viewer
659+
[:img.inline (-> node :attrs (update :src process-image-source (::doc node)))]))}
624660
{:name :nextjournal.markdown/blockquote :transform-fn (into-markup [:blockquote])}
625661
{:name :nextjournal.markdown/paragraph :transform-fn (into-markup [:p])}
626662
{:name :nextjournal.markdown/plain :transform-fn (into-markup [:<>])}
@@ -749,18 +785,15 @@
749785
(def image-viewer
750786
{#?@(:clj [:pred #(instance? BufferedImage %)
751787
:transform-fn (fn [{image :nextjournal/value}]
752-
(let [w (.getWidth image)
753-
h (.getHeight image)
754-
r (float (/ w h))]
755-
(-> {:nextjournal/value (.. (PngEncoder.)
756-
(withBufferedImage image)
757-
(withCompressionLevel 1)
758-
(toBytes))
759-
:nextjournal/content-type "image/png"
760-
:nextjournal/width (if (and (< 2 r) (< 900 w)) :full :wide)}
761-
mark-presented)))])
788+
(-> {:nextjournal/value (.. (PngEncoder.)
789+
(withBufferedImage image)
790+
(withCompressionLevel 1)
791+
(toBytes))
792+
:nextjournal/content-type "image/png"
793+
:nextjournal/width (image-width image)}
794+
mark-presented))])
762795
:render-fn '(fn [blob-or-url] [:div.flex.flex-col.items-center.not-prose
763-
[:img {:src #?(:clj (nextjournal.clerk.render/url-for blob-or-url)
796+
[:img {:src #?(:clj (nextjournal.clerk.render/url-for blob-or-url)
764797
:cljs blob-or-url)}]])})
765798

766799
(def ideref-viewer
@@ -1527,8 +1560,8 @@
15271560
(defn image
15281561
([image-or-url] (image {} image-or-url))
15291562
([viewer-opts image-or-url]
1530-
(with-viewer image-viewer viewer-opts #?(:clj (ImageIO/read (if (string? image-or-url) (URL. image-or-url) image-or-url))
1531-
:cljs image-or-url))))
1563+
(with-viewer image-viewer viewer-opts
1564+
#?(:cljs image-or-url :clj (read-image image-or-url)))))
15321565

15331566
(defn caption [text content]
15341567
(col

src/nextjournal/clerk/webserver.clj

+10-14
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
[editscript.core :as editscript]
88
[nextjournal.clerk.view :as view]
99
[nextjournal.clerk.viewer :as v]
10-
[org.httpkit.server :as httpkit]))
10+
[org.httpkit.server :as httpkit])
11+
(:import (java.nio.file Files)))
1112

1213
(defn help-hiccup []
1314
[:p "Call " [:span.code "nextjournal.clerk/show!"] " from your REPL"
@@ -90,20 +91,14 @@
9091
{:blob-id (str/replace uri "/_blob/" "")
9192
:fetch-opts (get-fetch-opts query-string)})
9293

93-
(defn serve-file [path {:as req :keys [uri]}]
94-
(let [file-or-dir (str path uri)
95-
file (when (fs/exists? file-or-dir)
96-
(cond-> file-or-dir
97-
(fs/directory? file-or-dir) (fs/file "index.html")))
98-
extension (fs/extension file)]
94+
(defn serve-file [uri path]
95+
(let [file (when (fs/exists? path)
96+
(cond-> path
97+
(fs/directory? path) (fs/file "index.html")))]
9998
(if (fs/exists? file)
10099
{:status 200
101-
:headers (cond-> {"Content-Type" ({"css" "text/css"
102-
"html" "text/html"
103-
"png" "image/png"
104-
"jpg" "image/jpeg"
105-
"js" "application/javascript"} extension "text/html")}
106-
(and (= "js" extension) (fs/exists? (str file ".map"))) (assoc "SourceMap" (str uri ".map")))
100+
:headers (cond-> {"Content-Type" (Files/probeContentType (fs/path file))}
101+
(and (= "js" (fs/extension file)) (fs/exists? (str file ".map"))) (assoc "SourceMap" (str uri ".map")))
107102
:body (fs/read-all-bytes file)}
108103
{:status 404})))
109104

@@ -152,7 +147,8 @@
152147
(try
153148
(case (get (re-matches #"/([^/]*).*" uri) 1)
154149
"_blob" (serve-blob @!doc (extract-blob-opts req))
155-
("build" "js" "css") (serve-file "public" req)
150+
("build" "js" "css") (serve-file uri (str "public" uri))
151+
("_fs") (serve-file uri (str/replace uri "/_fs/" ""))
156152
"_ws" {:status 200 :body "upgrading..."}
157153
{:status 200
158154
:headers {"Content-Type" "text/html"}

test/nextjournal/clerk/viewer_test.clj

+21-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
(ns nextjournal.clerk.viewer-test
2-
(:require [clojure.string :as str]
2+
(:require [babashka.fs :as fs]
3+
[clojure.string :as str]
34
[clojure.test :refer [deftest is testing]]
45
[clojure.walk :as w]
56
[matcher-combinators.test :refer [match?]]
@@ -202,6 +203,12 @@
202203
v/->viewer
203204
:closing-paren))))))
204205

206+
(defn tree-re-find [data re]
207+
(->> data
208+
(tree-seq coll? seq)
209+
(filter string?)
210+
(filter (partial re-find re))))
211+
205212
(deftest doc->viewer
206213
(testing "extraction of synced vars"
207214
(is (not-empty (-> (view/doc->viewer (eval/eval-string "(ns nextjournal.clerk.test.sync-vars (:require [nextjournal.clerk :as clerk]))
@@ -217,12 +224,20 @@
217224
(view/doc->viewer (eval/eval-string "(ns nextjournal.clerk.test.sync-vars (:require [nextjournal.clerk :as clerk]))
218225
^::clerk/sync (def sync-me (atom {:a (fn [x] x)}))")))))
219226

227+
(testing "Local images are served as blobs in show mode"
228+
(let [test-doc (eval/eval-string ";; Some inline image ![alt](trees.png) here.")]
229+
(is (not-empty (tree-re-find (view/doc->viewer test-doc) #"_fs/trees.png")))))
230+
231+
(testing "Local images are inlined in bundled static builds"
232+
(let [test-doc (eval/eval-string ";; Some inline image ![alt](trees.png) here.")]
233+
(is (not-empty (tree-re-find (view/doc->viewer {:bundle? true} test-doc) #"data:image/png;base64")))))
234+
235+
(testing "Local images are content addressed for unbundled static builds"
236+
(let [test-doc (eval/eval-string ";; Some inline image ![alt](trees.png) here.")]
237+
(is (not-empty (tree-re-find (view/doc->viewer {:bundle? false :out-path (str (fs/temp-dir))} test-doc) #"_data/.+\.png")))))
238+
220239
(testing "Doc options are propagated to blob processing"
221-
(let [test-doc (eval/eval-string "(java.awt.image.BufferedImage. 20 20 1)")
222-
tree-re-find (fn [data re] (->> data
223-
(tree-seq coll? seq)
224-
(filter string?)
225-
(filter (partial re-find re))))]
240+
(let [test-doc (eval/eval-string "(java.awt.image.BufferedImage. 20 20 1)")]
226241
(is (not-empty (tree-re-find (view/doc->viewer {:inline-results? true
227242
:bundle? true
228243
:out-path builder/default-out-path} test-doc)

trees.png

44.2 KB
Loading

0 commit comments

Comments
 (0)