Files
mesh-catalog/modules/brother/cmd/brother/html.go
T
jschoubben 6d7117a361
mesh/merge-gate head 6d7117a361b8 merged into trunk 43e0daf6947d — pass: builds brother → shanks; no bus step; every machine composes with the change as it…
mesh/repo-check head 6d7117a361b8 merged into trunk 43e0daf6947d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Never guess a label from another control's, form's or link's text; test the radio group with nothing checked (issue 1530)
2026-10-21 00:11:48 +02:00

401 lines
9.3 KiB
Go

package main
import (
"html"
"regexp"
"strings"
"unicode/utf8"
)
// The printer's admin pages are small, tolerant HTML (XHTML 1.0 with unclosed inputs). Only what the module
// reads is taken out of them: the title, the links, and the forms with their fields.
// Link is an anchor of a page.
type Link struct {
Href string `json:"href"`
Text string `json:"text"`
}
// Field is a named control of a form. A password field's value is never kept (ADR 0467 clause 4).
type Field struct {
Name string `json:"name"`
Type string `json:"type"`
Value string `json:"value"`
Checked *bool `json:"checked,omitempty"`
Label string `json:"label"`
Options []Option `json:"options,omitempty"`
id string
own string // the label of this control alone: <label for>, a wrapping <label>, or a radio button's text after it
row string // what the control's table row (or <dt>) calls it
}
// Option is a choice of a select (Value and Text) or a button of a radio group (Value and Label).
type Option struct {
Value string `json:"value"`
Label string `json:"label,omitempty"`
Text string `json:"text,omitempty"`
}
// Form is a form of a page with its named fields.
type Form struct {
Action string `json:"action"`
Method string `json:"method"`
Fields []Field `json:"fields"`
}
// Page is what a page of the admin site holds.
type Page struct {
Title string `json:"title"`
Links []Link `json:"links"`
Forms []Form `json:"forms"`
}
var attrRE = regexp.MustCompile(`([A-Za-z_:][-A-Za-z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?`)
// attrs reads a tag's attributes, names in lower case and values unescaped.
func attrs(s string) map[string]string {
out := map[string]string{}
for _, m := range attrRE.FindAllStringSubmatch(s, -1) {
v := m[2] + m[3] + m[4]
out[strings.ToLower(m[1])] = html.UnescapeString(v)
}
return out
}
func squash(s string) string { return strings.Join(strings.Fields(html.UnescapeString(s)), " ") }
// latin1 turns a body that is not UTF-8 into UTF-8, reading each byte as the character of the same number:
// the printer's pages say iso-8859-1.
func latin1(b []byte) string {
if utf8.Valid(b) {
return string(b)
}
r := make([]rune, len(b))
for i, c := range b {
r[i] = rune(c)
}
return string(r)
}
// tagEnd is the index of the '>' that closes the tag starting at s[0], skipping quoted values, or -1.
func tagEnd(s string) int {
var q byte
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case q != 0:
if c == q {
q = 0
}
case c == '"' || c == '\'':
q = c
case c == '>':
return i
}
}
return -1
}
// ParsePage reads a page. Unknown markup is skipped, and what is not closed is closed at the end.
func ParsePage(body []byte) Page {
s := latin1(body)
var p Page
var (
inTitle bool
title strings.Builder
link *Link
formOpen bool
form Form
labelFor string
inLabel bool
label strings.Builder
labelIDs = map[string]string{}
wrapped []int // fields inside a label that names none, by index in form.Fields
selIdx = -1
inOption bool
optValue string
optHasV bool
optSel bool
optText strings.Builder
inTextA bool
textA strings.Builder
textAIdx = -1
// Where a control stands in its row: Brother pages lay a setting out as <tr><th>name</th><td>control</td></tr>
// or <dt>name</dt><dd>control</dd>; the first cell names the row.
cellN int
cellOpen bool
cellText strings.Builder
rowFirst string
lastText string
tailIdx = -1
)
finishOption := func() {
if !inOption || selIdx < 0 {
inOption = false
return
}
v := optValue
if !optHasV {
v = squash(optText.String())
}
f := &form.Fields[selIdx]
if len(f.Options) == 0 {
f.Value = v // a browser shows the first option when none is selected
}
f.Options = append(f.Options, Option{Value: v, Text: squash(optText.String())})
if optSel {
f.Value = v
}
inOption = false
}
closeForm := func() {
finishOption()
selIdx = -1
if formOpen {
p.Forms = append(p.Forms, form)
}
formOpen = false
form = Form{}
}
addField := func(f Field) {
f.row = rowFirst
if (cellOpen && cellN == 1) || f.row == "" {
f.row = lastText
}
lastText = "" // text before a control names that control alone
form.Fields = append(form.Fields, f)
if inLabel && labelFor == "" {
wrapped = append(wrapped, len(form.Fields)-1)
}
}
text := func(t string) {
switch {
case inTitle:
title.WriteString(t)
case inOption:
optText.WriteString(t)
case inTextA:
textA.WriteString(t)
}
if !inTitle && !inOption && !inTextA && link == nil {
if q := squash(t); q != "" {
switch {
case tailIdx >= 0:
// the text right after a radio button is that button's, not the next field's
if form.Fields[tailIdx].own == "" {
form.Fields[tailIdx].own = q
}
case !inLabel:
lastText = q
}
}
if cellOpen && cellN == 1 {
cellText.WriteString(t)
}
}
if link != nil {
link.Text += t
}
if inLabel {
label.WriteString(t)
}
}
i := 0
for i < len(s) {
lt := strings.IndexByte(s[i:], '<')
if lt < 0 {
text(s[i:])
break
}
text(s[i : i+lt])
i += lt
switch {
case strings.HasPrefix(s[i:], "<!--"):
end := strings.Index(s[i:], "-->")
if end < 0 {
i = len(s)
} else {
i += end + 3
}
continue
case strings.HasPrefix(s[i:], "<!") || strings.HasPrefix(s[i:], "<?"):
end := strings.IndexByte(s[i:], '>')
if end < 0 {
i = len(s)
} else {
i += end + 1
}
continue
}
end := tagEnd(s[i:])
if end < 0 {
break
}
tailIdx = -1
raw := s[i+1 : i+end]
i += end + 1
closing := strings.HasPrefix(raw, "/")
raw = strings.TrimSuffix(strings.TrimPrefix(raw, "/"), "/")
name := raw
rest := ""
if sp := strings.IndexAny(raw, " \t\r\n"); sp >= 0 {
name, rest = raw[:sp], raw[sp:]
}
name = strings.ToLower(name)
if !closing && (name == "script" || name == "style") {
if e := strings.Index(strings.ToLower(s[i:]), "</"+name); e >= 0 {
i += e
} else {
i = len(s)
}
continue
}
a := map[string]string{}
if !closing {
a = attrs(rest)
}
switch name {
case "title":
inTitle = !closing
case "tr":
cellN, cellOpen, rowFirst, lastText = 0, false, "", ""
cellText.Reset()
case "td", "th", "dt", "dd":
if cellOpen && cellN == 1 {
rowFirst = squash(cellText.String())
}
cellOpen = false
if name == "dt" && !closing {
cellN, rowFirst, lastText = 0, "", ""
}
if !closing {
cellN++
if name == "dd" && cellN < 2 {
cellN = 2
}
cellOpen = true
cellText.Reset()
}
case "a":
if closing {
if link != nil {
link.Text = squash(link.Text)
p.Links = append(p.Links, *link)
}
link = nil
} else if href, ok := a["href"]; ok {
link = &Link{Href: href}
}
case "form":
closeForm()
lastText = ""
if !closing {
formOpen = true
form = Form{Action: a["action"], Method: strings.ToLower(a["method"])}
if form.Method == "" {
form.Method = "get"
}
}
case "label":
if closing {
t := squash(label.String())
if labelFor != "" {
labelIDs[labelFor] = t
}
for _, k := range wrapped {
form.Fields[k].own = t
}
inLabel, labelFor, wrapped = false, "", nil
label.Reset()
} else {
inLabel, labelFor = true, a["for"]
label.Reset()
}
case "input":
if closing || a["name"] == "" {
break
}
typ := strings.ToLower(a["type"])
if typ == "" {
typ = "text"
}
if typ == "submit" || typ == "button" || typ == "image" || typ == "reset" {
break
}
f := Field{Name: a["name"], Type: typ, Value: a["value"], id: a["id"]}
if typ == "password" {
f.Value = ""
}
if typ == "checkbox" || typ == "radio" {
_, on := a["checked"]
f.Checked = &on
}
addField(f)
if typ == "radio" {
tailIdx = len(form.Fields) - 1
}
case "select":
finishOption()
selIdx = -1
if !closing && a["name"] != "" {
addField(Field{Name: a["name"], Type: "select", id: a["id"]})
selIdx = len(form.Fields) - 1
}
case "option":
finishOption()
if !closing && selIdx >= 0 {
inOption = true
optValue, optHasV = a["value"], false
if _, ok := a["value"]; ok {
optHasV = true
}
_, optSel = a["selected"]
optText.Reset()
}
case "textarea":
if closing {
if inTextA && textAIdx >= 0 {
form.Fields[textAIdx].Value = html.UnescapeString(textA.String())
}
inTextA = false
} else if a["name"] != "" {
addField(Field{Name: a["name"], Type: "textarea", id: a["id"]})
inTextA, textAIdx = true, len(form.Fields)-1
textA.Reset()
}
}
}
closeForm()
p.Title = squash(title.String())
for fi := range p.Forms {
var fields []Field
groups := map[string]int{}
for k := range p.Forms[fi].Fields {
f := p.Forms[fi].Fields[k]
if f.own == "" && f.id != "" {
f.own = labelIDs[f.id]
}
if f.Type != "radio" {
f.Label = f.own
if f.Label == "" && f.Type != "hidden" {
f.Label = f.row
}
fields = append(fields, f)
continue
}
g, ok := groups[f.Name]
if !ok {
g = len(fields)
groups[f.Name] = g
fields = append(fields, Field{Name: f.Name, Type: "radio", Label: f.row})
}
fields[g].Options = append(fields[g].Options, Option{Value: f.Value, Label: f.own})
if f.Checked != nil && *f.Checked {
fields[g].Value = f.Value
}
}
p.Forms[fi].Fields = fields
}
return p
}