emacs-jupyter/ox-jupyter.el
Nathaniel Nicandro 21b318d773 WIP
2019-06-01 14:21:11 -05:00

278 lines
12 KiB
EmacsLisp

;; TODO: Encapsulate the notebook state in some struct so it is easier to
;; reason about which parts are stateful.
(require 'jupyter-messages) ; For `jupyter--empty-dict'
(require 'jupyter-kernelspec)
(eval-when-compile
(require 'ob-core); For `org-babel-map-src-blocks'
(require 'subr-x))
(org-export-define-derived-backend 'jupyter 'md
:translate-alist
'((src-block . jupyter-notebook-src-block)
(paragraph . jupyter-notebook-paragraph)
(section . jupyter-notebook-section)
(headline . jupyter-notebook-headline)
(fixed-width . jupyter-notebook-cell)
(inner-template . jupyter-notebook-json))
:menu-entry
'(?j "Export to Jupyter Notebook"
((?J "As Notebook" jupyter-org-export-as-notebook))))
(defsubst jupyter-notebook--markdown-cell-p (info)
(equal "markdown" (plist-get (jupyter-notebook--current-cell info) :cell_type)))
(defsubst jupyter-notebook--code-cell-p (info)
(equal "code" (plist-get (jupyter-notebook--current-cell info) :cell_type)))
(defsubst jupyter-notebook--result-drawer-p (elem)
(and (eq (org-element-type elem) 'drawer)
(equal "RESULTS"
(upcase (org-element-property :drawer-name elem)))))
;; From the `org-mode' documentation
;;
;; A section contains directly any greater element or element. Only a headline
;; can contain a section. As an exception, text before the first headline in
;; the document also belongs to a section.
;; (document
;; (section)
;; (headline
;; (section)
;; (headline)
;; (headline
;; (headline))))
;;
;; Once a section is complete, the headline nodes gets called so I need to have
;; some state that allows me to put the headline at the front.
;; Take the current cells and put them in a section, the node that gets called
;; right after this one is the headline node which is then responsible for
;; modifying the head of the section to add the headline
(defun jupyter-notebook-section (_data contents info)
(message "section %s" contents)
(let ((cells (plist-get info :jupyter-cells)))
(plist-put info :jupyter-sections
(cons cells (plist-get info :jupyter-sections)))
(plist-put info :jupyter-cells nil))
"")
;; TODO: Figure out when this gets called
(defun jupyter-notebook-headline (data contents info)
(message "headline %s" contents)
(let* ((cell (jupyter-notebook--current-cell info))
(source (plist-get cell :source)))
(plist-put cell :source
(nconc source
;; TODO: Be able to control when to create a new markdown
;; cell? At what level of subtree should markdown cells
;; be created? For top-level subtrees or have a subtree
;; property that states when to create a subtree?
(list "\n\n")
(list (concat (make-string (org-element-property :level data) ?#) " "
(org-element-property :raw-value data))))))
"")
(defun jupyter-notebook-paragraph (elem contents info)
(message "paragraph %s" contents)
(let (context)
(cond
((jupyter-notebook--markdown-cell-p info)
(let* ((cell (jupyter-notebook--current-cell info))
(source (plist-get cell :source)))
(plist-put cell :source
(nconc source
;; TODO: Be able to control when to create a new
;; markdown cell? At what level of subtree should
;; markdown cells be created? For top-level subtrees
;; or have a subtree property that states when to
;; create a subtree?
(list "\n\n")
(jupyter-notebook-split-source contents)))))
((jupyter-notebook--result-p
(save-excursion
(goto-char (jupyter-org-element-begin-after-affiliated elem))
;; To handle links we need to get the context
(setq context (org-element-context)))
info)
;; TODO: Rename this function to better represent what it does. It is a
;; node in the parse tree that gets a source block's results.
(jupyter-notebook-cell context nil info))
(t
(jupyter-notebook--begin-cell
(jupyter-notebook-markdown-cell
:source (jupyter-notebook-split-source contents))
info))))
"")
(defun jupyter-notebook--complete-cell (info)
(when-let* ((elem (plist-get info :jupyter-current-cell)))
;; TODO: Handle the execute-result output. The last output should be
;; translated into an execute result if need be. This will be somewhat
;; difficult to do since there is no way to distinguish between an execute
;; result or a stream or display-data result. So don't really know what the
;; strategy should be.
(let ((cells (plist-get info :jupyter-cells)))
(push elem cells)
(plist-put info :jupyter-cells cells)
(plist-put info :jupyter-current-cell nil))))
(defun jupyter-notebook--begin-cell (elem info)
(when (plist-get info :jupyter-current-cell)
(jupyter-notebook--complete-cell info))
(plist-put info :jupyter-current-cell elem))
(defun jupyter-notebook--current-cell (info)
(plist-get info :jupyter-current-cell))
(defun jupyter-notebook-src-block (src-block contents info)
(message "src-block %s" contents)
(jupyter-notebook--begin-cell
(jupyter-notebook-code-cell
;; TODO: Global execution count
:execution-count 0
:source (string-trim-right (org-element-property :value src-block)))
info)
""
;; TODO: (1) If INFO contains intermediate output, complete the previous cell
;; object
;;
;; TODO: (2) Start a source block object and store it in INFO as the
;; :jupyter-intermediate object. On the next element transcoded, if it
;; doesn't contain a RESULTS post-affiliated keyword, complete the source
;; block as not containing any outputs.
;;
;; TODO: (3) So source blocks act as boundaries on when to complete objects.
)
(defun jupyter-notebook--result-p (elem info)
"Return non-nil if ELEM corresponds to the results of a source block.
ELEM is the results of a source block if the current cell being
processed in INFO is a code cell, ELEM has a RESULTS affiliated
keyword, and ELEM passes `jupyter-org-babel-result-p'."
(and (jupyter-notebook--code-cell-p info)
;; If ELEM is associated with a #+RESULTS: keyword, it is considered a
;; result
(cl-loop
with el = elem
while el thereis (org-element-property :results el)
do (setq el (org-element-property :parent el)))
(or (jupyter-org-babel-result-p elem)
;; TODO: Isn't a result drawer also a babel result?
(jupyter-notebook--result-drawer-p elem))))
(defun jupyter-notebook--mime-bundle (elem)
;; TODO: Is there a better way?
;; NOTE: All elements are initially considered as display-data output, when
;; the corresponding source block cell is completed, the last element in the
;; :outputs key is converted into an execute-result if it should be.
(cl-case (org-element-type elem)
(table
(save-restriction
(narrow-to-region (org-element-property :begin elem)
(org-element-property :end elem))
(let ((org-html-format-table-no-css t))
(jupyter-notebook-display-data-output
:data (list :text/html (org-export-as 'html nil nil 'body-only))))))
;; Ambiguity between stream results and final result output
((fixed-width example-block)
(jupyter-notebook-stream-output
;; TODO: How to distinguish between stdout and stderr reliably? Maybe
;; hijack the switches of an example block or add as affiliated keywords
;; like ATTR_JUPYTER.
;;
;; The example block way would be
;;
;; #+BEGIN_EXAMPLE :stream err
;; #+END_EXAMPLE
:name "stdout"
:text (org-element-property :value elem)))
;; Image links
(link
(let* ((path (org-element-property :path elem))
(ext (file-name-extension path))
;; TODO: Generalize this into a function and also use in
;; `jupyter-org-client.el'
(mime (pcase ext
("png" :image/png)
("jpg" :image/jpeg)
("svg" :image/svg+xml)
(_ (error "Unsupported file name extension (%s)" ext)))))
(list mime (with-temp-buffer
(insert-file-contents-literally path)
(when (memq mime '(:image/png :image/jpeg))
(base64-encode-region (point-min) (point-max) 'no-line-breaks))
(buffer-string))))
)
(_
;; A result that passes `jupyter-org-babel-result-p'
;; TODO: One issue here is that results that look like tables get
;; converted into `org-mode' tables and information is lost there.
;; Probably the best option here is to convert into an `html' table.
)))
;; TODO: Remember that the export process is depth first, so the higher level
;; nodes like drawers and sections will already have parsed contents of the
;; elements they contain. What I need to do is tag with text properties parts
;; of the buffer that correspond to notebook elements, then during the export
;; process, check those properties to parse them differently.
;;
;; 1. Add a hook function to org-export-before-parsing-hook that marks org
;; elements that correspond to source block output as such.
;;
;; 2. During export, if an element has been marked as a source block output,
;; add it to the outputs of the current source block cell.
(defun jupyter-notebook-cell (elem contents info)
(message "cell %s" contents)
(if (jupyter-notebook--result-p elem info)
(let ((cell (jupyter-notebook--current-cell info)))
(plist-put
cell :outputs (vconcat
(plist-get cell :outputs)
(list (jupyter-notebook--mime-bundle elem))))
"")
(let (org-export-with-toc)
(org-export-data-with-backend elem 'md info))))
(defvar jupyter-notebook--kernelspec nil)
(defun jupyter-notebook-verify-kernelspec (backend)
"When BACKEND is jupyter, ensure all source blocks use the same kernelspec.
Raise an error if a source block uses a different kernel,
otherwise return the kernelspec used for the notebook."
(when (eq backend 'jupyter)
(let (spec)
(org-babel-map-src-blocks nil
(when (org-babel-jupyter-language-p lang)
(let* ((info (org-babel-get-src-block-info))
(block-spec
(car (jupyter-find-kernelspecs
(alist-get :kernel (nth 2 info))))))
(or spec (setq spec block-spec))
;; TODO: Go to error location
(unless (equal spec block-spec)
(user-error "Using different kernels in same notebook")))))
(setq jupyter-notebook--kernelspec
(cl-destructuring-bind (name _ . spec) spec
(list :name name
:display_name (plist-get spec :display_name)
:language (plist-get spec :language)))))))
(add-hook 'org-export-before-processing-hook #'jupyter-notebook-verify-kernelspec)
(defun jupyter-notebook-json (_ info)
"From the Jupyter related properties in INFO, return the notebook JSON."
;; Finish up the last cell
(jupyter-notebook--complete-cell info)
(json-encode
(list :cells (apply #'vector (nreverse (plist-get info :jupyter-cells)))
:metadata (list :kernelspec (prog1 (or jupyter-notebook--kernelspec
jupyter--empty-dict)
(setq jupyter-notebook--kernelspec nil))
:language_info jupyter--empty-dict
:widgets jupyter--empty-dict)
:nbformat 4
:nbformat_minor 1)))