├── 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 | 
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 | "")
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 `