src/main.lisp @ e73c4713b159

Implement peekable streams ourselves in the quest for zerodeps
author Steve Losh <steve@stevelosh.com>
date Sat, 03 Feb 2018 15:43:03 -0500
parents 6d68c2c64b0f
children 05f84301a061
(in-package :trivial-ppm)

;;;; Peekable Streams ---------------------------------------------------------
(defstruct (peekable-stream (:conc-name "")
                            (:constructor make-peekable-stream (s)))
  (p nil :type (or null (unsigned-byte 8)))
  (s (error "Required") :type stream))

(defun ps-actually-read-byte (stream &optional eof-error-p)
  (read-byte (s stream) eof-error-p nil))

(defun ps-read-byte (stream &optional eof-error-p)
  (if (p stream)
    (prog1 (p stream)
      (setf (p stream) nil))
    (ps-actually-read-byte stream eof-error-p)))

(defun ps-peek-byte (stream)
  (when (null (p stream))
    (setf (p stream) (ps-actually-read-byte stream)))
  (p stream))

(defun ps-unread-byte (stream byte)
  (assert (null (p stream)))
  (setf (p stream) byte)
  (values))


;;;; Utils --------------------------------------------------------------------
;;; The standard doesn't mandate that (char-code #\Space) must be equivalent to
;;; the ASCII code... if we're gonna do this thing, let's do it right.
(defconstant +space+ 32)
(defconstant +tab+ 9)
(defconstant +line-feed+ 10)
(defconstant +vertical-tab+ 11)
(defconstant +form-feed+ 12)
(defconstant +carriage-return+ 13)

(defconstant +comment-char+ 35)


(defun white-space-p (byte)
  (if (member byte (list +space+ +form-feed+
                         +tab+ +vertical-tab+
                         +line-feed+ +carriage-return+))
    t
    nil))

(defun line-terminator-p (byte)
  (if (member byte (list +line-feed+ +carriage-return+))
    t
    nil))


(defun skip-comment-body (stream)
  (loop :until (line-terminator-p (ps-read-byte stream t))))

(defun skip-whitespace (stream)
  (loop :for byte = (ps-read-byte stream)
        :while (white-space-p byte)
        :finally (ps-unread-byte stream byte)))


(defun error-junk (section byte)
  (error "Junk byte in ~A data: ~D (~S)" section byte (code-char byte)))

(defun read-raster-number (stream)
  "Read the next ASCII-encoded number from `stream` (does not allow comments)."
  (skip-whitespace stream)
  (loop :with i = nil
        :for byte = (ps-read-byte stream)
        :for digit = (when byte (digit-char-p (code-char byte)))
        :unless (or (null byte) digit (white-space-p byte))
        :do (error-junk "raster" byte)
        :while (and byte digit)
        :do (setf i (+ (* (or i 0) 10) digit))
        :finally (return i)))

(defun read-header-number (stream)
  "Read the next ASCII-encoded number from `stream` (allows comments)."
  (skip-whitespace stream)
  (loop :with i = nil
        :for byte = (ps-read-byte stream)
        :for digit = (when byte (digit-char-p (code-char byte)))
        :while byte
        :while (cond ((= byte +comment-char+) (skip-comment-body stream) t)
                     (digit (setf i (+ (* (or i 0) 10) digit)) t)
                     ((white-space-p byte) nil)
                     (t (error-junk "header" byte)))
        :finally (return i)))

(defun write-number (value stream)
  "Write `value` to stream as an ASCII-encoded number."
  (format stream "~D " value))


(defun read-magic-byte (stream)
  "Read the initial `P#` from `stream`, returning the magic `#` character."
  (assert (eql (read-byte stream) (char-code #\P)))
  (code-char (read-byte stream)))


(defun file-format (magic-byte)
  "Return `(values format binary?)` for the given magic byte character."
  (ecase magic-byte
    (#\1 (values :pbm nil))
    (#\2 (values :pgm nil))
    (#\3 (values :ppm nil))
    (#\4 (values :pbm t))
    (#\5 (values :pgm t))
    (#\6 (values :ppm t))))

(defun magic-byte (file-format binary?)
  "Return the magic byte character to use for the given format/encoding combination."
  (if binary?
    (ecase file-format
      (:pbm #\4)
      (:pgm #\5)
      (:ppm #\6))
    (ecase file-format
      (:pbm #\1)
      (:pgm #\2)
      (:ppm #\3))))


(defun pixel-type (format bit-depth)
  "Return the type specifier for a pixel of an image with the given `format` and `bit-depth`."
  (ecase format
    (:pbm 'bit)
    (:pgm `(integer 0 ,bit-depth))
    (:ppm `(simple-array (integer 0 ,bit-depth) (3)))))


;;;; PPM ----------------------------------------------------------------------
(defun read% (stream format binary?)
  (let* ((width (read-header-number stream))
         (height (read-header-number stream))
         (bit-depth (if (eql :pbm format) 1 (read-header-number stream)))
         (data (make-array (list width height)
                 :element-type (pixel-type format bit-depth)))
         (reader (if binary? #'read-byte #'read-raster-number)))
    (when binary?
      (read-char stream)) ; chomp last newline before bytes
    (dotimes (y height)
      (dotimes (x width)
        (setf (aref data x y)
              (ecase format
                (:pbm (- 1 (funcall reader stream)))
                (:pgm (funcall reader stream))
                (:ppm (make-array 3
                        :initial-contents (list (funcall reader stream)
                                                (funcall reader stream)
                                                (funcall reader stream))
                        :element-type 'fixnum))))))
    (values data format bit-depth)))

(defun write% (data stream format binary? maximum-value)
  (let ((stream (flexi-streams:make-flexi-stream stream :external-format :ascii))
        (writer (if binary? #'write-byte #'write-number)))
    (destructuring-bind (width height) (array-dimensions data)
      (format stream "P~D~%~D ~D~%~D~%"
              (magic-byte format binary?) width height maximum-value)
      (dotimes (y height)
        (dotimes (x width)
          (let ((pixel (aref data x y)))
            (ecase format
              (:pbm (funcall writer (- 1 pixel) stream))
              (:pgm (funcall writer pixel stream))
              (:ppm (progn (funcall writer (aref pixel 0) stream)
                           (funcall writer (aref pixel 1) stream)
                           (funcall writer (aref pixel 2) stream))))))
        (unless binary? (terpri stream))))))


;;;; API ----------------------------------------------------------------------
(defun read-from-stream (stream)
  "Read a PPM image file from `stream`, returning an array of pixels and more.

  `stream` must be a binary input stream.

  The primary return value will be a 2D array with dimensions `(width height)`.
  Each element of the array will be a single pixel whose type depends on the
  image file format:

  * PBM: `bit`
  * PGM: `(integer 0 bit-depth)`
  * PPM: `(simple-array (integer 0 bit-depth) (3))`

  Two other values are returned:

  * The format of the image that was read (one of `:pbm`, `:pgm`, `:ppm`).
  * The bit depth of the image.

  "
  (multiple-value-bind (format binary?)
      (file-format (read-magic-byte stream))
    (read% (make-peekable-stream stream) format binary?)))

(defun write-to-stream (stream data &key
                        (format :ppm)
                        (encoding :binary)
                        (bit-depth (ecase format (:pbm 1) ((:pgm :ppm) 255))))
  "Write a PPM image array `data` to `stream`.

  Nothing is returned.

  `format` must be one of `:pbm`, `:pgm`, `:ppm`.

  `encoding` must be one of `:binary`, `:ascii`.

  `bit-depth` must be the desired bit depth of the image (the maximum value any
  particular pixel can have).  For PBM images it must be `1`.

  For PBM and PGM images, `data` must be a two dimensional array of integers
  between `0` and `bit-depth` inclusive.

  For PPM images, `data` must be a two dimensional array of pixels, each of
  which must be a 3 element vector of integers between `0` and `bit-depth`
  inclusive.

  "
  (check-type format (member :ppm :pgm :pbm))
  (check-type encoding (member :binary :ascii))
  (if (eql format :pbm)
    (check-type bit-depth (eql 1))
    (check-type bit-depth (integer 1 *)))
  (write% data stream format (eql :binary encoding) bit-depth)
  (values))


(defun read-from-file (path)
  "Read a PPM image file from `path`, returning an array of pixels and more.

  The primary return value will be a 2D array with dimensions `(width height)`.
  Each element of the array will be a single pixel whose type depends on the
  image file format:

  * PBM: `bit`
  * PGM: `(integer 0 bit-depth)`
  * PPM: `(simple-array (integer 0 bit-depth) (3))`

  Two other values are returned:

  * The format of the image that was read (one of `:pbm`, `:pgm`, `:ppm`).
  * The bit depth of the image.

  "
  (with-open-file (s path :direction :input :element-type '(unsigned-byte 8))
    (read-from-stream s)))

(defun write-to-file (path data &key
                      (if-exists nil if-exists-given)
                      (format :ppm)
                      (encoding :binary)
                      (bit-depth (ecase format (:pbm 1) ((:pgm :ppm) 255))))
  "Write a PPM image array `data` to a file at `path`.

  Nothing is returned.

  `format` must be one of `:pbm`, `:pgm`, `:ppm`.

  `encoding` must be one of `:binary`, `:ascii`.

  `bit-depth` must be the desired bit depth of the image (the maximum value any
  particular pixel can have).  For PBM images it must be `1`.

  For PBM and PGM images, `data` must be a two dimensional array of integers
  between `0` and `bit-depth` inclusive.

  For PPM images, `data` must be a two dimensional array of pixels, each of
  which must be a 3 element vector of integers between `0` and `bit-depth`
  inclusive.

  "
  (flet ((write-it (stream)
           (write-to-stream stream data
                            :format format
                            :encoding encoding
                            :bit-depth bit-depth)))
    (if if-exists-given
      (with-open-file (s path :direction :output :if-exists if-exists :element-type '(unsigned-byte 8))
        (write-it s))
      (with-open-file (s path :direction :output :element-type '(unsigned-byte 8))
        (write-it s))))
  (values))