shithub: mycel

Download patch

ref: 4e15a670c166c11b3eb19a1ad6316c789b26513e
parent: f548ead614a4ee6fda902670100b49c17070c81b
author: Philip Silva <[email protected]>
date: Sun Jan 30 12:16:28 EST 2022

Parse media queries

Ported code from https://github.com/ericf/css-mediaquery

--- a/browser/browser_test.go
+++ b/browser/browser_test.go
@@ -210,7 +210,7 @@
 		return nil, nil, fmt.Errorf("parse url: %w", err)
 	}
 	b.History.Push(u, 0)
-	nm, err := style.FetchNodeMap(doc, style.AddOnCSS, 1280)
+	nm, err := style.FetchNodeMap(doc, style.AddOnCSS)
 	if err != nil {
 		return nil, nil, fmt.Errorf("FetchNodeMap: %w", err)
 	}
--- a/browser/experimental_test.go
+++ b/browser/experimental_test.go
@@ -46,7 +46,6 @@
 	}
 	fs.SetDOM(nt)
 	fs.Update(h, nil, scripts)
-	js.NewJS(h, nil, nt)
 	js.Start()
 	h, _, err = processJS2()
 	if err != nil {
--- a/browser/website.go
+++ b/browser/website.go
@@ -55,7 +55,7 @@
 
 			log.Printf("CSS size %v kB", cssSize/1024)
 
-			nm, err := style.FetchNodeMap(doc, css, 1280)
+			nm, err := style.FetchNodeMap(doc, css)
 			if err == nil {
 				if debugPrintHtml {
 					log.Printf("%v", nm)
@@ -175,9 +175,17 @@
 			}
 		case "link":
 			isStylesheet := n.Attr("rel") == "stylesheet"
-			isPrint := n.Attr("media") == "print"
+			if m := n.Attr("media"); m != "" {
+				matches, errMatch := style.MatchQuery(m, style.MediaValues)
+				if errMatch != nil {
+					log.Errorf("match query %v: %v", m, errMatch)
+				}
+				if !matches {
+					return
+				}
+			}
 			href := n.Attr("href")
-			if isStylesheet && !isPrint {
+			if isStylesheet {
 				url, err := f.LinkedUrl(href)
 				if err != nil {
 					log.Errorf("error parsing %v", href)
--- /dev/null
+++ b/style/media.go
@@ -1,0 +1,239 @@
+package style
+
+import (
+	"fmt"
+	"regexp"
+	"strconv"
+	"strings"
+)
+
+// Functions MatchQuery and parseQuery ported from
+// https://github.com/ericf/css-mediaquery
+// originally released as
+// Copyright (c) 2014, Yahoo! Inc. All rights reserved.
+// Copyrights licensed under the New BSD License.
+
+var (
+	reExpressions    = regexp.MustCompile(`\([^\)]+\)`)
+	reMediaQuery     = regexp.MustCompile(`^(?:(only|not)?\s*([_a-z][_a-z0-9-]*)|(\([^\)]+\)))(?:\s*and\s*(.*))?$`) // TODO: /i,
+	reMqExpression   = regexp.MustCompile(`^\(\s*([_a-z-][_a-z0-9-]*)\s*(?:\:\s*([^\)]+))?\s*\)$`)
+	reMqFeature      = regexp.MustCompile(`^(?:(min|max)-)?(.+)`)
+	reLengthUnit     = regexp.MustCompile(`(em|rem|px|cm|mm|in|pt|pc)?\s*$`)
+	reResolutionUnit = regexp.MustCompile(`(dpi|dpcm|dppx)?\s*$`)
+)
+
+type MediaQuery struct {
+	inverse bool
+	typ     string
+	exprs   []MediaExpr
+}
+
+type MediaExpr struct {
+	modifier string
+	feature  string
+	value    string
+}
+
+func MatchQuery(mediaQuery string, values map[string]string) (yes bool, err error) {
+	qs, err := parseQuery(mediaQuery)
+	if err != nil {
+		return false, fmt.Errorf("parse query: %v", err)
+	}
+	for _, q := range qs {
+		inverse := q.inverse
+		typeMatch := q.typ == "all" || values["type"] == q.typ
+		if (typeMatch && inverse) || !(typeMatch || inverse) {
+			continue
+		}
+
+		every := true
+		for _, expr := range q.exprs {
+			var valueFloat float64
+			var expValueFloat float64
+			feature := expr.feature
+			modifier := expr.modifier
+			expValue := expr.value
+			value := values[feature]
+
+			if value == "" {
+				every = false
+				break
+			}
+			switch feature {
+			case "orientation", "scan", "prefers-color-scheme":
+				if strings.ToLower(value) != strings.ToLower(expValue) {
+					every = false
+					break
+				}
+			case "width", "height", "device-width", "device-height":
+				if expValueFloat, err = toPx(expValue); err != nil {
+					break
+				}
+				if valueFloat, err = toPx(value); err != nil {
+					break
+				}
+			case "resolution":
+				if expValueFloat, err = toDpi(expValue); err != nil {
+					break
+				}
+				if valueFloat, err = toDpi(value); err != nil {
+					break
+				}
+			case "aspect-ratio", "device-aspect-ratio", /* Deprecated */ "device-pixel-ratio":
+				if expValueFloat, err = toDecimal(expValue); err != nil {
+					break
+				}
+				if valueFloat, err = toDecimal(value); err != nil {
+					break
+				}
+			case "grid", "color", "color-index", "monochrome":
+				var i int64
+				i, err = strconv.ParseInt(expValue, 10, 64)
+				if err != nil {
+					i = 1
+					err = nil
+				}
+				expValueFloat = float64(i)
+				i, err = strconv.ParseInt(value, 10, 64)
+				if err != nil {
+					i = 0
+					err = nil
+				}
+				valueFloat = float64(i)
+			}
+			switch modifier {
+			case "min":
+				every = valueFloat >= expValueFloat
+			case "max":
+				every = valueFloat <= expValueFloat
+			default:
+				every = valueFloat == expValueFloat
+			}
+		}
+		if (every && !inverse) || (!every && inverse) {
+			return true, nil
+		}
+	}
+	return false, nil
+}
+
+func parseQuery(mediaQuery string) (tokens []MediaQuery, err error) {
+	parts := strings.Split(mediaQuery, ",")
+	for _, q := range parts {
+		q = strings.TrimSpace(q)
+		captures := reMediaQuery.FindStringSubmatch(q)
+
+		if captures == nil {
+			return tokens, fmt.Errorf("Invalid CSS media query: %v", q)
+		}
+		modifier := captures[1]
+		parsed := MediaQuery{}
+		parsed.inverse = strings.ToLower(modifier) == "not"
+		if typ := captures[2]; typ == "" {
+			parsed.typ = "all"
+		} else {
+			parsed.typ = strings.ToLower(typ)
+		}
+
+		var exprs string
+		if len(captures) >= 4 {
+			exprs += captures[3]
+		}
+		if len(captures) >= 5 {
+			exprs += captures[4]
+		}
+		exprs = strings.TrimSpace(exprs)
+		if exprs == "" {
+			tokens = append(tokens, parsed)
+			continue
+		}
+
+		exprsList := reExpressions.FindStringSubmatch(exprs)
+		if exprsList == nil {
+			return tokens, fmt.Errorf("Invalid CSS media query: %v", q)
+		}
+		for _, expr := range exprsList {
+			var captures = reMqExpression.FindStringSubmatch(expr)
+
+			if captures == nil {
+				return tokens, fmt.Errorf("Invalid CSS media query: %v", q)
+			}
+			feature := reMqFeature.FindStringSubmatch(strings.ToLower(captures[1]))
+			parsed.exprs = append(parsed.exprs, MediaExpr{
+				modifier: feature[1],
+				feature:  feature[2],
+				value:    captures[2],
+			})
+		}
+		tokens = append(tokens, parsed)
+	}
+	return
+}
+
+// -- Utilities ----------------------------------------------------------------
+
+var reQuot = regexp.MustCompile(`^(\d+)\s*\/\s*(\d+)$`)
+
+func toDecimal(ratio string) (decimal float64, err error) {
+	decimal, err = strconv.ParseFloat(ratio, 64)
+	if err != nil {
+		numbers := reQuot.FindStringSubmatch(ratio)
+		if numbers == nil {
+			return 0, fmt.Errorf("cannot parse %v", ratio)
+		}
+		p, err := strconv.ParseFloat(numbers[0], 64)
+		if err != nil {
+			return 0, fmt.Errorf("cannot parse %v", p)
+		}
+		q, err := strconv.ParseFloat(numbers[1], 64)
+		if err != nil {
+			return 0, fmt.Errorf("cannot parse %v", q)
+		}
+		if q == 0 {
+			return 0, fmt.Errorf("division by zero")
+		}
+		decimal = p / q
+	}
+	return
+}
+
+func toDpi(resolution string) (value float64, err error) {
+	if value, err = strconv.ParseFloat(resolution, 64); err != nil {
+		return
+	}
+	units := reResolutionUnit.FindStringSubmatch(resolution)[1]
+
+	switch units {
+	case "dpcm":
+		value /= 2.54
+	case "dppx":
+		value *= 96
+	}
+	return
+}
+
+func toPx(length string) (value float64, err error) {
+	units := reLengthUnit.FindStringSubmatch(length)[1]
+	length = length[:len(length)-len(units)]
+	if value, err = strconv.ParseFloat(length, 64); err != nil {
+		return
+	}
+
+	switch units {
+	case "em":
+		value *= 16
+	case "rem":
+		value *= 16
+	case "cm":
+		value *= 96 / 2.54
+	case "mm":
+		value *= 96 / 2.54 / 10
+	case "in":
+		value *= 96
+	case "pt":
+		value *= 72
+	case "pc":
+		value *= 72 / 12
+	}
+	return
+}
--- /dev/null
+++ b/style/media_test.go
@@ -1,0 +1,50 @@
+package style
+
+import (
+	"testing"
+)
+
+func TestParseQuery(t *testing.T) {
+	mqs, err := parseQuery(`only screen and (max-width: 600px)`)
+	if err != nil {
+		t.Fail()
+	}
+	if len(mqs) != 1 {
+		t.Fail()
+	}
+	mq := mqs[0]
+	if mq.inverse || mq.typ != "screen" || len(mq.exprs) != 1 {
+		t.Fail()
+	}
+	expr := mq.exprs[0]
+	if expr.modifier != "max" || expr.feature != "width" || expr.value != "600px" {
+		t.Fail()
+	}
+}
+
+func TestMatchQuery(t *testing.T) {
+	matching := map[string]string{
+		"type": "screen",
+		"width": "500",
+	}
+	notMatching := map[string]string{
+		"type": "screen",
+		"width": "700",
+	}
+	yes, err := MatchQuery(`only screen and (max-width: 600px)`, matching)
+	if err != nil {
+		t.Fail()
+	}
+	t.Logf("%v", yes)
+	if !yes {
+		t.Fail()
+	}
+	yes, err = MatchQuery(`only screen and (max-width: 600px)`, notMatching)
+	if err != nil {
+		t.Fatalf("%v", err)
+	}
+	t.Logf("%v", yes)
+	if yes {
+		t.Fail()
+	}
+}
--- a/style/stylesheets.go
+++ b/style/stylesheets.go
@@ -23,14 +23,18 @@
 var dui *duit.DUI
 var availableFontNames []string
 
-var rMinWidth = regexp.MustCompile(`min-width:\s*(\d+)(px|em|rem)`)
-var rMaxWidth = regexp.MustCompile(`max-width:\s*(\d+)(px|em|rem)`)
-
 const FontBaseSize = 11.0
 
 var WindowWidth = 1280
 var WindowHeight = 1080
 
+var MediaValues = map[string]string{
+	"type": "screen",
+	"width": fmt.Sprintf("%vpx", WindowWidth),
+	"orientation": "landscape",
+	"prefers-color-scheme": "dark",
+}
+
 const AddOnCSS = `
 /* https://developer.mozilla.org/en-US/docs/Web/HTML/Inline_elements */
 a, abbr, acronym, audio, b, bdi, bdo, big, br, button, canvas, cite, code, data, datalist, del, dfn, em, embed, i, iframe, img, input, ins, kbd, label, map, mark, meter, noscript, object, output, picture, progress, q, ruby, s, samp, script, select, slot, small, span, strong, sub, sup, svg, template, textarea, time, u, tt, var, video, wbr {
@@ -72,8 +76,8 @@
 	}
 }
 
-func FetchNodeMap(doc *html.Node, cssText string, windowWidth int) (m map[*html.Node]Map, err error) {
-	mr, rv, err := FetchNodeRules(doc, cssText, windowWidth)
+func FetchNodeMap(doc *html.Node, cssText string) (m map[*html.Node]Map, err error) {
+	mr, rv, err := FetchNodeRules(doc, cssText)
 	if err != nil {
 		return nil, fmt.Errorf("fetch rules: %w", err)
 	}
@@ -114,7 +118,7 @@
 	return cascadia.ParseGroup(v)
 }
 
-func FetchNodeRules(doc *html.Node, cssText string, windowWidth int) (m map[*html.Node][]Rule, rVars map[string]string, err error) {
+func FetchNodeRules(doc *html.Node, cssText string) (m map[*html.Node][]Rule, rVars map[string]string, err error) {
 	m = make(map[*html.Node][]Rule)
 	rVars = make(map[string]string)
 	s, err := Parse(cssText, false)
@@ -164,28 +168,13 @@
 		}
 
 		// for media queries
-		if strings.Contains(r.Prelude, "print") && !strings.Contains(r.Prelude, "screen") {
-			continue
-		}
-		if rMaxWidth.MatchString(r.Prelude) {
-			m := rMaxWidth.FindStringSubmatch(r.Prelude)
-			l := m[1] + m[2]
-			maxWidth, _, err := length(nil, l)
+		if strings.HasPrefix(r.Prelude, "@media") {
+			p := strings.TrimPrefix(r.Prelude, "@media")
+			p = strings.TrimSpace(p)
+			yes, err := MatchQuery(p, MediaValues)
 			if err != nil {
-				return nil, nil, fmt.Errorf("atoi: %w", err)
-			}
-			if float64(windowWidth) > maxWidth {
-				continue
-			}
-		}
-		if rMinWidth.MatchString(r.Prelude) {
-			m := rMinWidth.FindStringSubmatch(r.Prelude)
-			l := m[1] + m[2]
-			minWidth, _, err := length(nil, l)
-			if err != nil {
-				return nil, nil, fmt.Errorf("atoi: %w", err)
-			}
-			if float64(windowWidth) < minWidth {
+				log.Errorf("match query %v: %v", r.Prelude, err)
+			} else if !yes {
 				continue
 			}
 		}
--- a/style/stylesheets_test.go
+++ b/style/stylesheets_test.go
@@ -1,6 +1,7 @@
 package style
 
 import (
+	"fmt"
 	"github.com/mjl-/duit"
 	"github.com/psilva261/opossum/logger"
 	"golang.org/x/net/html"
@@ -65,8 +66,9 @@
 }
 	`
 	for _, w := range []int{400, 800} {
+		MediaValues["width"] = fmt.Sprintf("%vpx", w)
 		t.Logf("w=%v", w)
-		m, _, err := FetchNodeRules(doc, css, w)
+		m, _, err := FetchNodeRules(doc, css)
 		if err != nil {
 			t.Fail()
 		}
@@ -128,7 +130,7 @@
 	if err != nil {
 		t.Fail()
 	}
-	m, _, err := FetchNodeRules(doc, AddOnCSS, 1024)
+	m, _, err := FetchNodeRules(doc, AddOnCSS)
 	if err != nil {
 		t.Fail()
 	}
@@ -180,7 +182,7 @@
 	if err != nil {
 		t.Fail()
 	}
-	m, err := FetchNodeMap(doc, AddOnCSS, 1024)
+	m, err := FetchNodeMap(doc, AddOnCSS)
 	if err != nil {
 		t.Fail()
 	}
@@ -197,7 +199,7 @@
 		t.Fail()
 	}
 	a := grep(doc, "a")
-	m, err := FetchNodeMap(doc, AddOnCSS, 1024)
+	m, err := FetchNodeMap(doc, AddOnCSS)
 	if err != nil {
 		t.Fail()
 	}
@@ -205,7 +207,7 @@
 	if nodeMap[a].Css("color") != "blue" {
 		t.Fatalf("%v", nodeMap[a])
 	}
-	m2, err := FetchNodeMap(doc, `.link { color: red; }`, 1024)
+	m2, err := FetchNodeMap(doc, `.link { color: red; }`)
 	if err != nil {
 		t.Fail()
 	}
@@ -454,7 +456,7 @@
 }
 	`
 
-	_, rv, err := FetchNodeRules(doc, css, 1280)
+	_, rv, err := FetchNodeRules(doc, css)
 	if err != nil {
 		t.Fail()
 	}
@@ -475,7 +477,7 @@
 	}
 	f(doc)
 
-	nm, err := FetchNodeMap(doc, css, 1280)
+	nm, err := FetchNodeMap(doc, css)
 	if err != nil {
 		t.Fail()
 	}