├── example.png ├── trivial-svg.asd ├── README.md └── trivial-svg.lisp /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/calsys456/trivial-svg/HEAD/example.png -------------------------------------------------------------------------------- /trivial-svg.asd: -------------------------------------------------------------------------------- 1 | (defsystem trivial-svg 2 | :author "April & May" 3 | :license "0BSD" 4 | :description "Pure Lisp SVG renderer." 5 | :depends-on (alexandria cl-ppcre plump serapeum vecto uiop) 6 | :components ((:file "trivial-svg"))) 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Trivial-SVG: Pure-Lisp SVG renderer 2 | 3 | This library allows you to render SVG images to PNG using [Vecto](https://www.xach.com/lisp/vecto/) and [zpb-ttf](https://github.com/xach/zpb-ttf). 4 | 5 | The library is purely written in Common Lisp with no foreign dependency. It's optimized for fast parsing and rendering. 0BSD licensed. 6 | 7 | ![example](example.png) 8 | 9 | > There're 168 Google Material Symbol SVGs in 32px above. The whole process of parsing + rendering + saving PNG cost 0.116 seconds in total. There's still many space for improvement. SBCL 2.5.0 on Apple M3 chip, renderer not cached. 10 | 11 | ## Package & Dependencies 12 | 13 | We provide a single ASDF system `trivial-svg` defined in a single file [trivial-svg.lisp](./trivial-svg.lisp). It depends on: 14 | 15 | - alexandria 16 | - cl-ppcre 17 | - plump 18 | - serapeum 19 | - vecto 20 | 21 | > 3/2/2025: Note that currently the Quicklisp distribution of `zpb-ttf` library (the necessary dependency of `vecto`) is staled. You should install the library manually with a version that at least later than [this commit](https://github.com/xach/zpb-ttf/commit/3b907c6f666cd3ed56ff2d469f23c5e26a709f2b). For example: 22 | > 23 | > ```bash 24 | > git clone https://github.com/xach/zpb-ttf.git ~/quicklisp/local-projects/zpb-ttf/ 25 | > ``` 26 | 27 | ## Usage 28 | 29 | To load the source code, you can clone this library to `~/common-lisp/` or `~/quicklisp/local-projects/`, then evaluate `(ql:quickload :trivial-svg)` if you have Quicklisp installed. 30 | 31 | ### Simple interface: `draw-svg-from-string` 32 | 33 | ``` common-lisp 34 | (defparameter *test-svg-string* 35 | " 37 | 38 | ") 39 | 40 | (draw-svg-from-string *test-svg-string* "./test.png") 41 | ``` 42 | 43 | You are supposed to see a 128*128px gray right-chevron at `./test.png`. 44 | 45 | *Function* **draw-svg-from-string** *svg-string output &key (font (get-font-file)) (viewport-width 1920) (viewport-height 1080)* => *NIL* 46 | 47 | Draw the first SVG element inside *svg-string*. If *svg-string* is a pathname, read the content of the file. *output* can be either a stream, a pathname, or a namestring. 48 | 49 | The output PNG image will be resized to fit the size of SVG. *viewport-width* and *viewport-height* specify the initial width and height of the drawing area, which will only be used for resolving relative CSS length units in the outermost SVG element. 50 | 51 | ### Custom Drawing with Vecto 52 | 53 | Trivial-SVG use the `graphics-state` of Vecto, which means it can cooperate with any Vecto drawing actions. 54 | 55 | > *Function* **create-renderer** *state* *node* *`&optional`* *(root-node node) (container-attributes (make-hash-table :test #'equalp))* => *function* 56 | > 57 | > The funcion `create-renderer` accepts a `plump:node` *node,* which should be a valid SVG element, and will parse it into a "renderer function". The `state` should be a `vecto::graphics-state` that is only used to query graphic information like geometry, font size and so on. `root-node` refers to the DOM root of the SVG element, it's used to search referenced elements by ID; `container-attributes` is used to store inherited attributes in recursive parsing. 58 | > 59 | > The result function accepts 1 argument: The `vecto::graphics-state` you want to draw the SVG with. Each time when the function is called, the function will execute a series of Vecto drawing functions to draw the SVG on the state at predefined location. You can use functions like `vecto::%translate` to change the drawing result. 60 | 61 | Here's the code that's used to produce [example.png](./example.png), which can be an example for custom drawing implementation. The source code of `draw-svg-from-string` is also valuable for reference. 62 | 63 | ```common-lisp 64 | (let* ((width (* 32 24)) 65 | (height (* 32 7)) 66 | (state (make-instance 'vecto::graphics-state 67 | :width width 68 | :height height 69 | :transform-matrix (vecto::identity-matrix))) 70 | ;; We put 42 Google material symbols in ~/svg-test/ 71 | (svg-files (directory "~/svg-test/*.svg"))) 72 | (setf (vecto::image state) 73 | (make-instance 'zpng:png 74 | :width width 75 | :height height 76 | :color-type vecto::+png-color-type+) 77 | (vecto::clipping-path state) (vecto::make-clipping-path width height)) 78 | (vecto::%set-font state (vecto::%get-font state (trivial-svg:get-font-file)) 12) 79 | (loop for i from 0 80 | for svg-file in (serapeum:repeat-sequence svg-files 4) 81 | for svg-node = (first (plump:get-elements-by-tag-name (plump:parse svg-file) "svg")) 82 | do (multiple-value-bind (y x) (floor i 24) 83 | (let* ((state (vecto::copy state)) 84 | (renderer (trivial-svg:create-renderer state svg-node))) 85 | (vecto::%translate state (* x 32) (* y 32)) 86 | (funcall renderer state)))) 87 | (vecto::after-painting state) 88 | (zpng:write-png (vecto::image state) 89 | (merge-pathnames "example.png" (asdf:system-source-directory :trivial-svg))) 90 | (vecto::clear-state state)) 91 | ``` 92 | 93 | > Note: The exported interface of `Vecto` is too simple to handle complex drawing operations, you may need to refer the source code of `Vecto` to learn how to do. It's not difficult if you have the experience of any computer drawing system. 94 | 95 | ## Development 96 | 97 | The plugin has not fully covered the SVG specification yet. It may misbehave when dealing with elements that are unsupported or complexly cascaded. Here's a list that it can deal with, emphasized means only partly implemented: 98 | 99 | - Elements 100 | - circle 101 | - defs 102 | - ellipse 103 | - g 104 | - image 105 | - line 106 | - *linearGradient* 107 | - path 108 | - polygon 109 | - polyline 110 | - *radialGradient* 111 | - rect 112 | - stop 113 | - svg 114 | - *text* 115 | - *tspan* 116 | - use 117 | - presentation attributes 118 | - color 119 | - fill 120 | - fill-opacity 121 | - fill-rule 122 | - font-family 123 | - font-size 124 | - font-weight 125 | - font-style 126 | - opacity 127 | - stop-color 128 | - stop-opacity 129 | - stroke 130 | - stroke-dasharray 131 | - stroke-linecap 132 | - stroke-linejoin 133 | - stroke-opacity 134 | - stroke-width 135 | - transform 136 | - _writing-mode_ 137 | 138 | Here's what we're planning to do now: 139 | 140 | - [x] [BUG] Fix the wrong interpretation of rotational transform matrix 141 | - [x] Support specifying attributes with inline `style` attribute and CSS `style` element 142 | - [ ] Support `text` and `tspan` 143 | - [x] `x`, `y`, `dx`, `dy`, `rotate` 144 | - [x] Get font with different weight/slant with `zpb-ttf` on all platforms 145 | - [ ] perhaps we can optimize its speed... 146 | - [ ] Character and word spacing 147 | - [ ] SVG2 Content area & SVG tiny `textArea` 148 | - [ ] ... 149 | 150 | - [ ] Support `image`, `marker` and `symbol` (and maybe `a`?) 151 | - [ ] Support gradients `spreadMethod` and `pattern` 152 | - [ ] Support clipping and masking 153 | - [ ] Formal error handling 154 | 155 | ### Testing 156 | 157 | At the end of the [trivial-svg.lisp](./trivial-svg.lisp) there's an "interactive test", it will load a same SVG side-by-side, one using the `capi:browser-pane`, another using the `capi:output-pane` with `trivial-svg` library. It allows us to compare and check if the two images are identical on the screen. Images used for test are example images shown on W3C's SVG specification. You need LispWorks for Macintosh to run the interactive test, as `capi:browser-pane` does not support other Unix, and on Windows it is too old to use. 158 | 159 | ## Implementation Notes 160 | 161 | This library is a port of [lw-svg](https://github.com/apr3vau/lw-plugins/tree/main/svg), with Vecto and zpb-ttf instead of LispWorks Graphics Port API, making it available for every implementation. The developer will maintain a synchronic update between the two library. 162 | 163 | Since Vecto & cl-vector is not as powerful as LW-GP, the image quality produced by two libraries may slightly different. 164 | 165 | To simplify porting, we wrote a partial implementation of LispWorks APIs, mainly Graphics Ports and Color packages, acting as a portable layer. These APIs are keeping consistent with LW data structure, and can be converted to conforms other libraries like `Vecto`. With this layer, we can constraint the difference between lw-svg and trivial-svg into a small area, which have simplified our works significantly. 166 | 167 | There's also a partial CSS parser inside the code. Take them if they're useful for you. 168 | 169 | --- 170 | 171 | ## Acknowledgements 172 | 173 | Thanks to Zachary P. Beane, and other contributors of `vecto`, `cl-vectors`, `zpb-ttf` and other libraries. Your endeavor for the infrastructure is the basis of this project. 174 | 175 | Thanks my sister Simone, and my headmate May, who help and support me. 176 | 177 | Supporting Neurodiversity & Transgender & Plurality! 178 | 179 | 🏳️‍🌈🏳️‍⚧️ 180 | -------------------------------------------------------------------------------- /trivial-svg.lisp: -------------------------------------------------------------------------------- 1 | ;; Copyright (c) 2025, April & May 2 | ;; SPDX-License-Identifier: 0BSD 3 | 4 | ;; Pure-Lisp SVG renderer. 5 | 6 | ;; The source code can be separated to - major parts, splitted with #\Page 7 | ;; 1. Utilities and a partial implementation of LispWorks APIs 8 | ;; 2. CSS parser 9 | ;; 3. SVG `path` data parser 10 | ;; 4. Gradient painting server implementation 11 | ;; 5. Main SVG parser & renderer 12 | 13 | ;; Check README.md for usage and details. 14 | 15 | (defpackage trivial-svg 16 | (:use #:cl #:string-case #:split-sequence) 17 | (:import-from #:alexandria #:clamp #:copy-hash-table #:lerp #:ensure-gethash #:with-unique-names #:when-let #:if-let) 18 | (:import-from #:serapeum #:push-end #:parse-float #:defalias) 19 | (:export 20 | rad-to-deg deg-to-rad hex-to-spec 21 | get-char-width get-char-height font-family font-subfamily find-font-loader get-font-file 22 | css-parse-url css-parse-angel css-parse-color css-parse-length 23 | css-parse-a-number css-parse-transforms css-parse-numeric-color css-parse-all-angels-from-string 24 | css-parse-all-length-from-string css-parse-all-numbers-from-string 25 | create-renderer draw-svg-from-string)) 26 | 27 | (in-package trivial-svg) 28 | 29 | 30 | ;; Utilities and a partial implementation of LispWorks APIs 31 | 32 | (declaim (type double-float rad-to-deg-conversion-factor 2pi pi-by-2)) 33 | 34 | ;; GP:2PI, GP:PI-BY-2 35 | (defconstant 2pi (* pi 2)) 36 | (defconstant pi-by-2 (/ pi 2)) 37 | 38 | (defconstant rad-to-deg-conversion-factor (/ 180.0d0 pi) 39 | "Factor used to convert radiants to degrees by multiplication.") 40 | 41 | (defun rad-to-deg (radians) (* radians rad-to-deg-conversion-factor)) 42 | 43 | (defun deg-to-rad (degree) (/ degree rad-to-deg-conversion-factor)) 44 | 45 | (defmacro with-nth (bindings list &body body) 46 | (with-unique-names (lst) 47 | `(let* ((,lst ,list)) 48 | (symbol-macrolet 49 | ,(loop for i from 0 50 | for var in bindings 51 | collect `(,var (nth ,i ,lst))) 52 | ,@body)))) 53 | 54 | ;; serapeum's version has mysterous inline-expand warning on LispWorks, annoying... 55 | (defun string-prefix-p (prefix string) 56 | (string-equal prefix string :end2 (length prefix))) 57 | 58 | ;; Why I'm prefer using CL's convention `NFOO` but not Scheme's `FOO!` XD... 59 | (defun nmerge-tables (table &rest tables) 60 | "Merge values of hash-tables in TABLES into TABLE. TABLE will be modified. 61 | 62 | From serapeum's `merge-tables!`" 63 | (declare (optimize (speed 3))) 64 | (reduce (lambda (ht1 ht2) 65 | (maphash (lambda (k v) 66 | (setf (gethash k ht1) v)) 67 | ht2) 68 | ht1) 69 | tables 70 | :initial-value table)) 71 | 72 | ;; HARLEQUIN-COMMON-LISP:STRING-TRIM-WHITESPACE 73 | (defalias string-trim-whitespace #'serapeum:trim-whitespace) 74 | 75 | ;; LISPWORKS:WHITESPACE-CHAR-P 76 | (defalias whitespace-char-p #'serapeum:whitespacep) 77 | 78 | ;; LISPWORKS:STRING-APPEND 79 | (defalias string-append #'serapeum:string+) 80 | 81 | ;; GRAPHICS-PORTS:RECTANGLE-BIND 82 | (defmacro rectangle-bind ((x y w h) rect &body body) 83 | (with-unique-names (r) 84 | `(let* ((,r ,rect) 85 | (,x (first ,r)) 86 | (,y (second ,r)) 87 | (,w (third ,r)) 88 | (,h (fourth ,r))) 89 | ,@body))) 90 | 91 | ;; Color 92 | 93 | ;; COLOR:MAKE-RGB, COLOR:MAKE-HSV 94 | (defun make-rgb (r g b &optional (a 1.0)) 95 | (vector :rgb r g b a)) 96 | (defun make-hsv (h s v &optional (a 1.0)) 97 | (vector :hsv h s v a)) 98 | 99 | ;; COLOR:ENSURE-RGB 100 | (defun ensure-rgb (color) 101 | (case (aref color 0) 102 | (:hsv (let* ((h (rad-to-deg (mod (aref color 1) 2pi))) 103 | (s (aref color 2)) 104 | (v (aref color 3)) 105 | (c (* s v)) 106 | (x (* c (- 1 (abs (1- (mod (/ h 60) 2)))))) 107 | (m (- v c)) 108 | (rp (cond ((or (and (<= 0 h) (< h 60)) 109 | (and (<= 300 h) (< h 360))) 110 | c) 111 | ((or (and (<= 60 h) (< h 120)) 112 | (and (<= 240 h) (< h 300))) 113 | x) 114 | (t 0))) 115 | (gp (cond ((or (and (<= 0 h) (< h 60)) 116 | (and (<= 180 h) (< h 240))) 117 | x) 118 | ((and (<= 60 h) (< h 180)) 119 | c) 120 | (t 0))) 121 | (bp (cond ((or (and (<= 120 h) (< h 180)) 122 | (and (<= 300 h) (< h 360))) 123 | x) 124 | ((and (<= 180 h) (< h 300)) 125 | c) 126 | (t 0)))) 127 | (make-rgb (+ rp m) (+ gp m) (+ bp m) (aref color 4)))) 128 | (t color))) 129 | 130 | ;; LW RGB color accessors 131 | (defun color-red (color) 132 | (declare (inline color-red)) 133 | (aref (ensure-rgb color) 1)) 134 | (defun color-green (color) 135 | (declare (inline color-green)) 136 | (aref (ensure-rgb color) 2)) 137 | (defun color-blue (color) 138 | (declare (inline color-blue)) 139 | (aref (ensure-rgb color) 3)) 140 | (defun color-alpha (color) 141 | (declare (inline color-alpha)) 142 | (aref color 4)) 143 | 144 | (defun hex-to-spec (hex) 145 | "Convert hexdecimal color string to LW color-spec." 146 | (setq hex (string-trim '(#\# #\Space) hex)) 147 | (let ((hex-list (case (length hex) 148 | (3 (map 'list #'string hex)) 149 | (6 (loop for i from 0 to 4 by 2 collect (subseq hex i (+ 2 i)))))) 150 | (deno (if (< (length hex) 6) 15.0 255.0))) 151 | (when hex-list 152 | (apply #'make-rgb 153 | (mapcar (lambda (str) (/ (parse-integer str :radix 16) deno)) 154 | hex-list))))) 155 | 156 | ;; Transform 157 | 158 | ;; Notes that these transforms are in List (conform with LW) but not 159 | ;; Vector (like in Vecto). 160 | 161 | ;; GRAPHICS-PORTS:MAKE-TRANSFORM 162 | (defun make-transform (&optional (a 1) (b 0) (c 0) (d 1) (e 0) (f 0)) 163 | (declare (inline make-transform)) 164 | (list a b c d e f)) 165 | 166 | (defalias copy-transform #'copy-list) 167 | 168 | ;; Inspired by VECTO 169 | 170 | ;; GRAPHICS-PORTS:PREMULTIPLY-TRANSFORMS 171 | (defun premultiply-transforms (transform1 transform2) 172 | (destructuring-bind (a b c d e f) transform2 173 | (destructuring-bind (a* b* c* d* e* f*) transform1 174 | (with-nth (a1 b1 c1 d1 e1 f1) transform1 175 | (setf a1 (+ (* a a*) (* b c*)) 176 | b1 (+ (* a b*) (* b d*)) 177 | c1 (+ (* c a*) (* d c*)) 178 | d1 (+ (* c b*) (* d d*)) 179 | e1 (+ (* e a*) (* f c*) e*) 180 | f1 (+ (* e b*) (* f d*) f*))))) 181 | transform1) 182 | 183 | ;; GRAPHICS-PORTS:POSTMULTIPLY-TRANSFORMS 184 | (defun postmultiply-transforms (transform1 transform2) 185 | (destructuring-bind (a b c d e f) transform1 186 | (destructuring-bind (a* b* c* d* e* f*) transform2 187 | (with-nth (a1 b1 c1 d1 e1 f1) transform1 188 | (setf a1 (+ (* a a*) (* b c*)) 189 | b1 (+ (* a b*) (* b d*)) 190 | c1 (+ (* c a*) (* d c*)) 191 | d1 (+ (* c b*) (* d d*)) 192 | e1 (+ (* e a*) (* f c*) e*) 193 | f1 (+ (* e b*) (* f d*) f*))))) 194 | transform1) 195 | 196 | ;; GRAPHICS-PORTS:APPLY-SCALE, GRAPHICS-PORTS:APPLY-TRANSLATION, 197 | ;; GRAPHICS-PORTS:APPLY-ROTATION, GRAPHICS-PORTS:APPLY-ROTATION-AROUND-POINT 198 | (defun apply-translation (transform dx dy) 199 | (declare (inline apply-translation)) 200 | (postmultiply-transforms transform (make-transform 1 0 0 1 dx dy))) 201 | 202 | (defun apply-scale (transform sx sy) 203 | (declare (inline apply-scale)) 204 | (postmultiply-transforms transform (make-transform sx 0 0 sy 0 0))) 205 | 206 | (defun apply-rotation (transform theta) 207 | (declare (inline apply-rotation)) 208 | (postmultiply-transforms 209 | transform 210 | (let ((cos (cos theta)) 211 | (sin (sin theta))) 212 | (make-transform cos sin (- sin) cos 0 0)))) 213 | 214 | (defun apply-rotation-around-point (transform theta x y) 215 | (declare (inline apply-rotation-around-point)) 216 | (apply-translation transform (- x) (- y)) 217 | (apply-rotation transform theta) 218 | (apply-translation transform x y)) 219 | 220 | ;; Fonts 221 | 222 | (defun get-char-width (char loader size) 223 | (let* ((scale (vecto::loader-font-scale size loader)) 224 | (glyph (zpb-ttf:find-glyph char loader))) 225 | (* (zpb-ttf:advance-width glyph) scale))) 226 | 227 | (defun get-char-height (char loader size) 228 | (let* ((scale (vecto::loader-font-scale size loader)) 229 | (glyph (zpb-ttf:find-glyph char loader))) 230 | (* (zpb-ttf:advance-height glyph) scale))) 231 | 232 | (defun get-glyph-width (char loader size) 233 | (let* ((scale (vecto::loader-font-scale size loader)) 234 | (glyph (zpb-ttf:find-glyph char loader))) 235 | (* (- (zpb-ttf:xmax glyph) (zpb-ttf:xmin glyph)) scale))) 236 | 237 | (defun get-glyph-height (char loader size) 238 | (let* ((scale (vecto::loader-font-scale size loader)) 239 | (glyph (zpb-ttf:find-glyph char loader))) 240 | (* (- (zpb-ttf:ymax glyph) (zpb-ttf:ymin glyph)) scale))) 241 | 242 | (defun font-family (font-loader) 243 | (loop for entry across (zpb-ttf::name-entries font-loader) 244 | when (= (zpb-ttf::name-id entry) 1) ; 1 = (zpb-ttf::name-identifier-id :font-family) 245 | return (zpb-ttf:value entry))) 246 | 247 | (defun font-subfamily (font-loader) 248 | (loop for entry across (zpb-ttf::name-entries font-loader) 249 | when (= (zpb-ttf::name-id entry) 2) ; 2 = (zpb-ttf::name-identifier-id :font-subfamily) 250 | return (zpb-ttf:value entry))) 251 | 252 | (defun font-subfamily-match-p (font-loader weight slant) 253 | ;; FIXME: not perfect. e.g. semibold? 254 | (let ((sub (font-subfamily font-loader))) 255 | (cond ((and (or (null weight) (string-equal weight "regular")) 256 | (or (null slant) (string-equal slant "roman"))) 257 | (string-equal sub "regular")) 258 | ((or (null weight) (string-equal weight "regular")) 259 | (string-equal slant sub)) 260 | ((or (null slant) (string-equal slant "roman")) 261 | (string-equal weight sub)) 262 | (t (and (search weight sub :test #'char-equal) 263 | (search slant sub :test #'char-equal)))))) 264 | 265 | ;; A small utility to find font file within system-installed fonts. 266 | ;; Require fc-list on Linux. 267 | 268 | (defvar *default-font-family* 269 | #+mswindows "C:/Windows/Fonts/Arial.ttf" 270 | #+darwin "/System/Library/Fonts/HelveticaNeue.ttc" 271 | #+(and unix (not darwin)) "Liberation Sans") 272 | 273 | (defun find-font-loader (family-or-file weight slant) 274 | (let* (result fallback) 275 | #+(or mswindows darwin) 276 | (dolist (file (if (pathnamep family-or-file) (list family-or-file) 277 | (directory 278 | (make-pathname :name :wild :type :wild :defaults 279 | #+mswindows #P"C:/Windows/Fonts/" 280 | #+darwin #P"/System/Library/Fonts/**/")))) 281 | (when (member (pathname-type file) '("ttf" "ttc" "otf" "otc") :test #'string-equal) 282 | (handler-case 283 | (let ((loader (zpb-ttf:open-font-loader file))) 284 | (if (string-equal (font-family loader) family-or-file) 285 | (if (font-subfamily-match-p loader weight slant) 286 | (progn (setq result loader) 287 | (return)) 288 | (let ((count (zpb-ttf:collection-font-count loader))) 289 | (if fallback 290 | (zpb-ttf:close-font-loader loader) 291 | (setq fallback loader)) 292 | (when (> count 1) 293 | (loop named t 294 | for index from 1 to count 295 | for loader = (zpb-ttf:open-font-loader file :collection-index index) 296 | when (font-subfamily-match-p loader weight slant) 297 | do (setq result loader) 298 | (return))))) 299 | (zpb-ttf:close-font-loader loader))) 300 | (zpb-ttf::regrettable-hex-value (e) nil) 301 | (error (e) nil)))) 302 | #-(or mswindows darwin) 303 | (dolist (line (split-sequence #\Newline 304 | (with-output-to-string (*standard-output*) 305 | (uiop:run-program "fc-list" :output t)) 306 | :remove-empty-subseqs t)) 307 | ;; We don't use `style` field from fc-list output here, 308 | ;; as we need an exact index of the font from collection. 309 | (destructuring-bind (file family &rest style) (split-sequence #\: line) 310 | (declare (ignore style)) 311 | (when (string-equal (string-trim-whitespace family) family-or-file) 312 | (handler-case 313 | (let ((loader (zpb-ttf:open-font-loader file))) 314 | (if (font-subfamily-match-p loader weight slant) 315 | (progn (setq result loader) 316 | (return)) 317 | (let ((count (zpb-ttf:collection-font-count loader))) 318 | (if fallback 319 | (zpb-ttf:close-font-loader loader) 320 | (setq fallback loader)) 321 | (when (> count 1) 322 | (loop named t 323 | for index from 1 to count 324 | for loader = (zpb-ttf:open-font-loader file :collection-index index) 325 | when (font-subfamily-match-p loader weight slant) 326 | do (setq result loader) 327 | (return)))))) 328 | (zpb-ttf::regrettable-hex-value (e) nil) 329 | (error (e) nil))))) 330 | (if result 331 | (progn (when fallback (zpb-ttf:close-font-loader fallback)) 332 | result) 333 | fallback))) 334 | 335 | (defun get-font-file (&optional family-or-file) 336 | (if family-or-file 337 | (when (or (not (or (pathname-directory family-or-file) 338 | (pathname-type family-or-file))) 339 | (not (probe-file family-or-file))) 340 | #+mswindows 341 | (or (find-if (lambda (file) 342 | (member (pathname-type file) '("ttf" "ttc" "otf" "otc") 343 | :test #'string=)) 344 | (directory (make-pathname :name family-or-file :type :wild :defaults #P"C:/Windows/Fonts/"))) 345 | *default-font-family*) 346 | #+darwin 347 | (or (find-if (lambda (file) 348 | (member (pathname-type file) '("ttf" "ttc" "otf" "otc") 349 | :test #'string=)) 350 | (directory (make-pathname :name family-or-file :type :wild :defaults #P"/System/Library/Fonts/"))) 351 | *default-font-family*) 352 | #+(and unix (not darwin)) 353 | (let ((fc-list (mapcar (lambda (str) (first (split-sequence #\: str))) 354 | (split-sequence 355 | #\Newline 356 | (with-output-to-string (*standard-output*) 357 | (uiop:run-program "fc-list" :output t)))))) 358 | (or (find (string-append "/" family-or-file ".") fc-list :test #'search) 359 | (find family-or-file fc-list :test #'search) 360 | (find *default-font-description* fc-list :test #'search) 361 | (error "Cannot find available font. Please give a pathname to :FAMILY")))) 362 | *default-font-family*)) 363 | 364 | 365 | ;; (Partial) CSS parser 366 | 367 | (defun css-parse-a-number (str &optional (start 0)) 368 | (declare (type vector str) (type fixnum start) 369 | ;(:explain :types) 370 | ) 371 | "Parsing a CSS number gracefully out of STR, starting from START. 372 | 373 | Return the first number it met, and the end position of this number. 374 | Return NIL if there isn't a number at START." 375 | ;; This function will be used heavily, so should be as fast as it can... 376 | (check-type str string) 377 | (let ((char-arr (make-array 8 :element-type 'character :fill-pointer 0 :adjustable t)) 378 | (i start) 379 | (len (length str)) 380 | has-dot 381 | has-exp) 382 | (declare (type vector char-arr exp) 383 | (type fixnum i len)) 384 | (tagbody 385 | start 386 | (if (= i len) (go end)) 387 | (let ((c (char str i))) 388 | (cond ((member c '(#\+ #\-)) 389 | (if (= i start) 390 | (vector-push-extend c char-arr) 391 | (go end))) 392 | ((char= c #\.) 393 | (if has-dot (go end) 394 | (progn 395 | (setq has-dot t) 396 | (vector-push-extend c char-arr)))) 397 | ((char-equal c #\e) 398 | (if has-exp (go end) 399 | (progn 400 | (setq has-exp t) 401 | (vector-push-extend c char-arr)))) 402 | ((digit-char-p c) 403 | (vector-push-extend c char-arr)) 404 | (t (go end)))) 405 | (setq i (1+ i)) 406 | (go start) 407 | end) 408 | (if (find-if #'digit-char-p char-arr) 409 | (values (parse-float char-arr :type 'double-float) i) 410 | nil))) 411 | 412 | (defun css-parse-all-numbers-from-string (str) 413 | "Parse all CSS format numbers in STR, return as a vector." 414 | (declare (optimize (safety 0)) 415 | (type string str)) 416 | (let ((i 0) 417 | (len (length str)) 418 | (numbers (make-array 15 :element-type 'double-float :fill-pointer 0 :adjustable t))) 419 | (declare (type fixnum i len)) 420 | (tagbody 421 | start 422 | (if (= i len) (go end) nil) 423 | (let ((c (char str i))) 424 | (if (or (digit-char-p c) (member c '(#\+ #\-))) 425 | (multiple-value-bind (num idx) 426 | (css-parse-a-number str i) 427 | (if num 428 | (progn 429 | (vector-push-extend num numbers) 430 | (setq i idx) 431 | (go start)) 432 | (go end))) 433 | (progn 434 | (setq i (1+ i)) 435 | (go start)))) 436 | end) 437 | numbers)) 438 | 439 | ;; CSS length-percentage 440 | 441 | (defun css-parse-length (state str &optional (width-or-height :width) viewport-w viewport-h parent-w parent-h) 442 | "Parse a CSS format to corresponding pixels, 443 | based on current graphics port, CSS viewport and element's parent." 444 | ;; FIXME: Not fully tested 445 | (unless viewport-w (setq viewport-w (vecto::width state))) 446 | (unless viewport-h (setq viewport-h (vecto::height state))) 447 | (unless parent-w (setq parent-w viewport-w)) 448 | (unless parent-h (setq parent-h viewport-h)) 449 | (when str 450 | (if (alpha-char-p (char str 0)) 0 451 | (multiple-value-bind (len len-end) (css-parse-a-number str) 452 | (declare (type fixnum len-end viewport-w viewport-h parent-w parent-h) 453 | (type double-float len)) 454 | (let* ((unit (subseq str len-end)) 455 | (font (vecto::font-loaders state)) 456 | (size (vecto::size (vecto::font state)))) 457 | (* len 458 | (string-case (unit) 459 | ;; abs https://www.w3.org/TR/css-values/#absolute-lengths 460 | ("cm" 37.79527559055118D0) ;(/ 96d0 2.54d0) 461 | ("mm" 3.7795275590551185D0) ;(/ 96d0 25.4d0) 462 | ("Q" 0.9448818897637794D0) ;(/ 96d0 2.54d0 40d0) 463 | ("in" 96d0) 464 | ("pt" 1.3333333333333333D0) ;(coerce 4/3 'double-float) 465 | ("pc" 16d0) 466 | ("px" 1d0) 467 | ;; rel https://www.w3.org/TR/css-values/#relative-lengths 468 | ("em" size) 469 | ("rem" size) 470 | ;; FIXME: not precise value 471 | ("ex" (get-glyph-height #\x font size)) 472 | ("rex" (get-glyph-height #\x font size)) 473 | ("cap" (get-glyph-height #\O font size)) 474 | ("ch" (get-glyph-width #\0 font size)) 475 | ("rch" (get-glyph-width #\0 font size)) 476 | ("ic" (get-char-width #\ideographic-space font size)) 477 | ("ric" (get-char-width #\ideographic-space font size)) 478 | ("lh" (let* ((scale (vecto::loader-font-scale size font)) 479 | (glyph (zpb-ttf:find-glyph "M" font))) 480 | (* (+ (zpb-ttf:advance-width glyph) (zpb-ttf:line-gap font)) scale))) 481 | ("rlh" (let* ((scale (vecto::loader-font-scale size font)) 482 | (glyph (zpb-ttf:find-glyph "M" font))) 483 | (* (+ (zpb-ttf:advance-width glyph) (zpb-ttf:line-gap font)) scale))) 484 | ("vw" (/ viewport-w 100d0)) 485 | ("vi" (/ viewport-w 100d0)) 486 | ("vh" (/ viewport-h 100d0)) 487 | ("vb" (/ viewport-h 100d0)) 488 | ("vmin" (min (/ viewport-w 100d0) (/ viewport-h 100d0))) 489 | ("vmax" (max (/ viewport-w 100d0) (/ viewport-h 100d0))) 490 | ("%" (if (eq width-or-height :width) 491 | (/ parent-w 100) 492 | (/ parent-h 100))) 493 | (t 1d0)))))))) 494 | 495 | (defparameter *css-length-percentage-scanner* 496 | (ppcre:create-scanner 497 | "-?((\\d+(\\.\\d+)?)|(\\.\\d+))([eE]\\d+)?(?:cm|mm|Q|in|pt|pc|px|r?em|r?ex|cap|r?ch|r?ic|r?lh|vw|vi|vh|vb|vmin|vmax|%)?")) 498 | 499 | (defun css-parse-all-length-from-string (state str &optional (width-or-height :width) viewport-w viewport-h parent-w parent-h) 500 | "Parse all CSS from STR and convert them to pixels." 501 | (mapcar (lambda (sub) 502 | (css-parse-length state sub width-or-height viewport-w viewport-h parent-w parent-h)) 503 | (ppcre:all-matches-as-strings *css-length-percentage-scanner* str))) 504 | 505 | ;; CSS color 506 | 507 | (defvar *css-color-keywords* (make-hash-table :test #'equalp :size 149) 508 | "A map of CSS basic color keywords, from name to LW color spec. 509 | 510 | https://www.w3.org/TR/css-color-3/#html4") 511 | 512 | (dolist (i '(("aliceblue" "#F0F8FF") ("antiquewhite" "#FAEBD7") ("aqua" "#00FFFF") ("aquamarine" "#7FFFD4") 513 | ("azure" "#F0FFFF") ("beige" "#F5F5DC") ("bisque" "#FFE4C4") ("black" "#000000") 514 | ("blanchedalmond" "#FFEBCD") ("blue" "#0000FF") ("blueviolet" "#8A2BE2") ("brown" "#A52A2A") 515 | ("burlywood" "#DEB887") ("cadetblue" "#5F9EA0") ("chartreuse" "#7FFF00") ("chocolate" "#D2691E") 516 | ("coral" "#FF7F50") ("cornflowerblue" "#6495ED") ("cornsilk" "#FFF8DC") ("crimson" "#DC143C") 517 | ("cyan" "#00FFFF") ("darkblue" "#00008B") ("darkcyan" "#008B8B") ("darkgoldenrod" "#B8860B") 518 | ("darkgray" "#A9A9A9") ("darkgreen" "#006400") ("darkgrey" "#A9A9A9") ("darkkhaki" "#BDB76B") 519 | ("darkmagenta" "#8B008B") ("darkolivegreen" "#556B2F") ("darkorange" "#FF8C00") ("darkorchid" "#9932CC") 520 | ("darkred" "#8B0000") ("darksalmon" "#E9967A") ("darkseagreen" "#8FBC8F") ("darkslateblue" "#483D8B") 521 | ("darkslategray" "#2F4F4F") ("darkslategrey" "#2F4F4F") ("darkturquoise" "#00CED1") ("darkviolet" "#9400D3") 522 | ("deeppink" "#FF1493") ("deepskyblue" "#00BFFF") ("dimgray" "#696969") ("dimgrey" "#696969") 523 | ("dodgerblue" "#1E90FF") ("firebrick" "#B22222") ("floralwhite" "#FFFAF0") ("forestgreen" "#228B22") 524 | ("fuchsia" "#FF00FF") ("gainsboro" "#DCDCDC") ("ghostwhite" "#F8F8FF") ("gold" "#FFD700") 525 | ("goldenrod" "#DAA520") ("gray" "#808080") ("green" "#008000") ("greenyellow" "#ADFF2F") 526 | ("grey" "#808080") ("honeydew" "#F0FFF0") ("hotpink" "#FF69B4") ("indianred" "#CD5C5C") 527 | ("indigo" "#4B0082") ("ivory" "#FFFFF0") ("khaki" "#F0E68C") ("lavender" "#E6E6FA") 528 | ("lavenderblush" "#FFF0F5") ("lawngreen" "#7CFC00") ("lemonchiffon" "#FFFACD") ("lightblue" "#ADD8E6") 529 | ("lightcoral" "#F08080") ("lightcyan" "#E0FFFF") ("lightgoldenrodyellow" "#FAFAD2") ("lightgray" "#D3D3D3") 530 | ("lightgreen" "#90EE90") ("lightgrey" "#D3D3D3") ("lightpink" "#FFB6C1") ("lightsalmon" "#FFA07A") 531 | ("lightseagreen" "#20B2AA") ("lightskyblue" "#87CEFA") ("lightslategray" "#778899") ("lightslategrey" "#778899") 532 | ("lightsteelblue" "#B0C4DE") ("lightyellow" "#FFFFE0") ("lime" "#00FF00") ("limegreen" "#32CD32") 533 | ("linen" "#FAF0E6") ("magenta" "#FF00FF") ("maroon" "#800000") ("mediumaquamarine" "#66CDAA") 534 | ("mediumblue" "#0000CD") ("mediumorchid" "#BA55D3") ("mediumpurple" "#9370DB") ("mediumseagreen" "#3CB371") 535 | ("mediumslateblue" "#7B68EE") ("mediumspringgreen" "#00FA9A") ("mediumturquoise" "#48D1CC") ("mediumvioletred" "#C71585") 536 | ("midnightblue" "#191970") ("mintcream" "#F5FFFA") ("mistyrose" "#FFE4E1") ("moccasin" "#FFE4B5") 537 | ("navajowhite" "#FFDEAD") ("navy" "#000080") ("oldlace" "#FDF5E6") ("olive" "#808000") 538 | ("olivedrab" "#6B8E23") ("orange" "#FFA500") ("orangered" "#FF4500") ("orchid" "#DA70D6") 539 | ("palegoldenrod" "#EEE8AA") ("palegreen" "#98FD98") ("paleturquoise" "#AFEEEE") ("palevioletred" "#DB7093") 540 | ("papayawhip" "#FFEFD5") ("peachpuff" "#FFDAB9") ("peru" "#CD853F") ("pink" "#FFC0CD") 541 | ("plum" "#DDA0DD") ("powderblue" "#B0E0E6") ("purple" "#800080") ("red" "#FF0000") 542 | ("rosybrown" "#BC8F8F") ("royalblue" "#4169E1") ("saddlebrown" "#8B4513") ("salmon" "#FA8072") 543 | ("sandybrown" "#F4A460") ("seagreen" "#2E8B57") ("seashell" "#FFF5EE") ("sienna" "#A0522D") 544 | ("silver" "#C0C0C0") ("skyblue" "#87CEEB") ("slateblue" "#6A5ACD") ("slategray" "#708090") 545 | ("slategrey" "#708090") ("snow" "#FFFAFA") ("springgreen" "#00FF7F") ("steelblue" "#4682B4") 546 | ("tan" "#D2B48C") ("teal" "#008080") ("thistle" "#D8BFD8") ("tomato" "#FF6347") 547 | ("turquoise" "#40E0D0") ("saddlebrown" "#8B4513") ("violet" "#EE82EE") ("wheat" "#F5DEB3") 548 | ("white" "#FFFFFF") ("whitesmoke" "#F5F5F5") ("yellow" "#FFFF00") ("yellowgreen" "#9ACD32"))) 549 | (setf (gethash (first i) *css-color-keywords*) 550 | (hex-to-spec (second i)))) 551 | 552 | (setf (gethash "transparent" *css-color-keywords*) (make-rgb 0.0 0.0 0.0 0.0)) 553 | 554 | ;; For scanning valid arguments of the numeric color function 555 | ;; e.g. rgb(255, 0, 0) 556 | (defparameter *css-color-numeric-regexp* (ppcre:create-scanner "[\\d\\-\\.%]+")) 557 | 558 | (defun css-parse-numeric-color (str) 559 | "Parse a CSS numerical color value to LW color spec. 560 | 561 | https://www.w3.org/TR/css-color-3/#numerical" 562 | (let ((params (ppcre:all-matches-as-strings *css-color-numeric-regexp* str))) 563 | (flet ((parse-num (str) 564 | (let ((num (parse-integer str :junk-allowed t))) 565 | (if (eql (char str (1- (length str))) #\%) 566 | (/ (clamp num 0 100) 100) 567 | (/ (clamp num 0 255) 255))))) 568 | (let ((func (cond ((string-prefix-p "rgb" str) #'make-rgb) 569 | ((string-prefix-p "hsl" str) #'make-hsv)))) 570 | (destructuring-bind (x y z) (mapcar #'parse-num (subseq params 0 3)) 571 | (if (= (length params) 4) 572 | (let ((alpha (clamp (parse-float (nth 3 params)) 0.0 1.0))) 573 | (funcall func x y z alpha)) 574 | (funcall func x y z))))))) 575 | 576 | (defun css-parse-color (str) 577 | "Parse a valid CSS color to LW color spec." 578 | (unless (or (null str) (member str '("none" "auto") :test #'equalp)) 579 | (if (eql (char str 0) #\#) 580 | (hex-to-spec str) 581 | (if (or (string-prefix-p "rgb" str) (string-prefix-p "hsl" str)) 582 | (css-parse-numeric-color str) 583 | (gethash str *css-color-keywords*))))) 584 | 585 | ;; CSS angle 586 | 587 | (defun css-parse-angel (str &optional (start 0)) 588 | "Parse a CSS to radians from START of the STR. 589 | 590 | Return the radians and the end of parsing. 591 | 592 | https://www.w3.org/TR/css3-values/#angles" 593 | (let ((len (length str))) 594 | (multiple-value-bind (num end-pos) (css-parse-a-number str start) 595 | (unless (null num) 596 | (cond ((search "grad" str :start2 end-pos :end2 (min len (+ end-pos 4))) 597 | (values (deg-to-rad (* num 0.9d0)) (+ end-pos 4))) 598 | ((search "rad" str :start2 end-pos :end2 (min len (+ end-pos 3))) 599 | (values num (+ end-pos 3))) 600 | ((search "turn" str :start2 end-pos :end2 (min len (+ end-pos 4))) 601 | (values (* num 2pi) (+ end-pos 4))) 602 | ((search "deg" str :start2 end-pos :end2 (min len (+ end-pos 3))) 603 | (values (deg-to-rad num) (+ end-pos 3))) 604 | (t (values (deg-to-rad num) end-pos))))))) 605 | 606 | (defun css-parse-all-angels-from-string (str) 607 | "Parse a CSS to radians in STR, return as a vector" 608 | (let ((i 0) 609 | (len (length str)) 610 | (numbers (make-array 15 :element-type 'double-float :fill-pointer 0 :adjustable t))) 611 | (declare (type fixnum i len)) 612 | (tagbody 613 | start 614 | (if (= i len) (go end) nil) 615 | (let ((c (char str i))) 616 | (if (or (digit-char-p c) (member c '(#\+ #\-))) 617 | (multiple-value-bind (num idx) 618 | (css-parse-angel str i) 619 | (if num 620 | (progn 621 | (vector-push-extend num numbers) 622 | (setq i idx) 623 | (go start)) 624 | (go end))) 625 | (progn 626 | (setq i (1+ i)) 627 | (go start)))) 628 | end) 629 | numbers)) 630 | 631 | ;; CSS transform 632 | 633 | (defparameter *transform-scanner* 634 | (ppcre:create-scanner "(matrix|scale(?:X|Y)?|translate(?:X|Y)?|rotate|skew(?:X|Y)?)\\((?:.|\\s)+?\\)") 635 | "Scanner for searching CSS s. 636 | 637 | https://www.w3.org/TR/css-transforms-1/#transform-functions") 638 | 639 | (defun css-parse-transforms (state str &optional viewport-w viewport-h parent-w parent-h) 640 | "Parse a CSS transform property value to a list of TRANSFORMs. 641 | 642 | https://www.w3.org/TR/css-transforms-1/#transform-property" 643 | (let (transforms) 644 | (ppcre:do-scans (match-start match-end fname-starts fname-ends 645 | *transform-scanner* str) 646 | (declare (ignore match-start)) 647 | (setq fname-starts (aref fname-starts 0) 648 | fname-ends (aref fname-ends 0)) 649 | (let* ((fname (subseq str fname-starts fname-ends)) 650 | (args-str (subseq str (1+ fname-ends) (1- match-end))) 651 | (args (cond ((member fname '("rotate" "skew" "skewX" "skewY") :test #'string=) 652 | (css-parse-all-angels-from-string args-str)) 653 | ((member fname '("translate" "translateX" "translateY") :test #'string=) 654 | (split-sequence-if (lambda (c) (member c '(#\, #\Space))) args-str 655 | :remove-empty-subseqs t)) 656 | (t (css-parse-all-numbers-from-string args-str))))) 657 | (if (string= fname "matrix") 658 | (push-end (apply #'make-transform (coerce args 'list)) transforms) 659 | (let ((trans (make-transform))) 660 | (string-case (fname) 661 | ("scale" (apply-scale trans (aref args 0) (aref args (if (= (length args) 1) 0 1)))) 662 | ("scaleX" (apply-scale trans (aref args 0) 1)) 663 | ("scaleY" (apply-scale trans 1 (aref args 0))) 664 | ("translate" (apply-translation 665 | trans 666 | (css-parse-length state (first args) :width viewport-w viewport-h parent-w parent-h) 667 | (css-parse-length state (or (second args) (first args)) :height viewport-w viewport-h parent-w parent-h))) 668 | ("translateX" (apply-translation 669 | trans 670 | (css-parse-length state (first args) :width viewport-w viewport-h parent-w parent-h) 671 | 1)) 672 | ("translateY" (apply-translation 673 | trans 1 674 | (css-parse-length state (first args) :height viewport-w viewport-h parent-w parent-h))) 675 | ("rotate" (apply-rotation trans (aref args 0))) 676 | ("skew" 677 | (setf (nth 1 trans) (tan (aref args 0))) 678 | (setf (nth 2 trans) (if (= (length args) 1) 0 (tan (aref args 0))))) 679 | ("skewX" (setf (nth 1 trans) (tan (aref args 0)))) 680 | ("skewY" (setf (nth 2 trans) (tan (aref args 0))))) 681 | (push-end trans transforms))))) 682 | transforms)) 683 | 684 | ;; CSS url 685 | 686 | (defparameter *css-url-scanner* 687 | (ppcre:create-scanner "(?:url\\(\"(.+)\"\\))|(?:url\\('(.+)'\\))|(?:url\\((.+)\\))")) 688 | 689 | (defun css-parse-url (str root-node) 690 | "Return the element targeted by t§he URL expression inside STR." 691 | (multiple-value-bind (whole arr) 692 | (ppcre:scan-to-strings *css-url-scanner* str) 693 | (declare (ignore whole)) 694 | (let ((url (or (aref arr 0) (aref arr 1) (aref arr 2)))) 695 | (if (or (null url) (not (eql (char url 0) #\#))) 696 | (error "LW-SVG only support ID url selector.") 697 | (plump-dom:get-element-by-id root-node (subseq url 1)))))) 698 | 699 | ;; CSS `style` parser 700 | 701 | (defun css-parse-style-properties (str) 702 | "Parse CSS style content STR to a hash-table" 703 | (setq str (string-trim-whitespace str)) 704 | (let ((table (make-hash-table :test #'equalp))) 705 | (mapcar (lambda (str) 706 | (destructuring-bind (name val) 707 | (mapcar #'string-trim-whitespace (split-sequence #\: str :remove-empty-subseqs t)) 708 | (setf (gethash name table) val))) 709 | (mapcar #'string-trim-whitespace (split-sequence #\; str :remove-empty-subseqs t))) 710 | table)) 711 | 712 | (defmacro css-parse-class (node) 713 | `(split-sequence #\Space (plump:attribute ,node "class") :remove-empty-subseqs t)) 714 | 715 | (defparameter *css-class-name-scanner* 716 | (ppcre:create-scanner "[A-Za-z][A-Za-z0-9\\-_]*")) 717 | 718 | (defparameter *css-id-scanner* 719 | (ppcre:create-scanner "[A-Za-z][A-Za-z0-9\\-_:\\.]*")) 720 | 721 | (defparameter *css-attribute-selector-scanner* 722 | (ppcre:create-scanner "\\[([A-Za-z]+)((?:~|\\|)?=)?([A-Za-z]+)?\\]")) 723 | 724 | (defun css-parse-a-selector (str) 725 | "Parse one CSS selector from a CSS selector list (separated by #\,) 726 | 727 | Return a function that accept one argument PLUMP:NODE, which will 728 | return a non-nil value if the node conforms the selector." 729 | (let ((index 0) 730 | (len (length str)) 731 | funcs) 732 | (tagbody 733 | start 734 | (let ((first-char (char str index))) 735 | (case first-char 736 | (#\. (multiple-value-bind (start end) 737 | (ppcre:scan *css-class-name-scanner* str :start index) 738 | (let ((cla (subseq str start end))) 739 | (push (lambda (node) (member cla (css-parse-class node) :test #'string=)) funcs)) 740 | (setq index end))) 741 | (#\# (multiple-value-bind (start end) 742 | (ppcre:scan *css-id-scanner* str :start index) 743 | (let ((id (subseq str start end))) 744 | (push (lambda (node) (equal id (plump:attribute node "id"))) funcs)) 745 | (setq index end))) 746 | (#\[ (multiple-value-bind (start end rs re) 747 | (ppcre:scan *css-attribute-selector-scanner* str :start index) 748 | (declare (ignore start)) 749 | (let ((attr (subseq str (aref rs 0) (aref re 0))) 750 | (op (when (aref rs 1) 751 | (subseq str (aref rs 1) (aref re 1)))) 752 | (val (when (aref rs 2) 753 | (string-trim '(#\") (subseq str (aref rs 2) (aref re 2)))))) 754 | (push 755 | (if op 756 | (case (char op 0) 757 | (#\= (lambda (node) (equal (plump:attribute node attr) val))) 758 | (#\~ (lambda (node) 759 | (member val (split-sequence #\Space (plump:attribute node attr)) 760 | :test #'equal))) 761 | (#\| (lambda (node) 762 | (let ((x (plump:attribute node attr))) 763 | (or (equal x val) 764 | (and (stringp x) 765 | (string-prefix-p (string-append x "-") val))))))) 766 | (lambda (node) (plump:attribute node attr))) 767 | funcs)) 768 | (setq index end))) 769 | (#\Space (let ((prev-func (pop funcs)) 770 | (sub-func (css-parse-a-selector (subseq str (1+ index))))) 771 | (push 772 | (lambda (node) 773 | (and (loop for parent = (plump:parent node) then (plump:parent parent) 774 | until (or (plump:root-p parent) (null parent)) 775 | thereis (funcall prev-func parent)) 776 | (funcall sub-func node))) 777 | funcs) 778 | (setq index len))) 779 | (t (if (alpha-char-p first-char) 780 | (let* ((end (or (position-if-not #'alpha-char-p str :start index) len)) 781 | (name (subseq str index end))) 782 | (push (lambda (node) (equal name (plump:tag-name node))) funcs) 783 | (setq index end)) 784 | (setq index len))))) 785 | (if (< index len) 786 | (go start))) 787 | (lambda (node) 788 | (every (lambda (func) (funcall func node)) funcs)))) 789 | 790 | (defun css-parse-selectors (str) 791 | "Parse a CSS selector list. 792 | 793 | Return a function that accept one argument PLUMP:NODE, which will 794 | return a non-nil value if the node conforms the selector." 795 | (let ((selectors (mapcar #'css-parse-a-selector 796 | (mapcar #'string-trim-whitespace 797 | (split-sequence #\, str :remove-empty-subseqs t))))) 798 | (lambda (node) 799 | (some (lambda (func) (funcall func node)) selectors)))) 800 | 801 | (defparameter *css-style-block-scanner* 802 | (ppcre:create-scanner "([^\\{\\}]+?)\\{([^\\{\\}]+?)\\}")) 803 | 804 | (defun css-parse-style-element (node) 805 | "Giving a `