Files
Duc-Tam Nguyen 4e920c57ec fix: correct mobile layout for paulgraham.com-style table pages
Three issues surfaced when viewing the ZIM in Kiwix iOS:

1. Blank column at top: hiding img[usemap] left its containing <td> as an
   empty box. Now td:has(>img[usemap]) hides the entire nav column.

2. Content clipped on the right: the inner content table had width="435"
   as an HTML attribute. Added [width]{width:auto!important} to cancel all
   fixed HTML attribute widths on any element (tables, tds, imgs).

3. Spacer column: the 26px <td> holding a 1x1 transparent GIF kept its
   allocated space. td:has(>img[src*="trans_1x1"]:only-child) hides it.

Also: overflow-x:hidden on body, box-sizing:border-box globally, and
img{max-width:100%;height:auto} so any inline images stay within column.
2026-06-19 15:55:28 +07:00

402 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package sanitize removes every trace of JavaScript from an HTML document so
// the saved page is inert: a photograph, not a program.
//
// It parses with golang.org/x/net/html, walks the tree, and deletes scripts,
// event handlers, javascript: URLs, downlevel IE conditional comments (which
// can smuggle a <script> past an element-only walk), and the dead
// preconnect/preload hints that mean nothing offline — while leaving styles,
// images, fonts, forms, and all semantic markup untouched so the layout
// survives intact.
package sanitize
import (
"bytes"
"strings"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
// Options tune a few edge behaviours; the zero value is the safe default
// (scripts and noscript removed, meta-refresh removed).
type Options struct {
// KeepNoscript unwraps <noscript> content into the document instead of
// deleting it, for sites whose real content hides behind a JS check.
KeepNoscript bool
// KeepMetaRefresh preserves a plain timed <meta http-equiv="refresh">
// (a JS-target refresh is always removed).
KeepMetaRefresh bool
// Banner, when non-empty, is inserted as an HTML comment at the top of the
// document.
Banner string
// MobileReadable injects a viewport meta tag and a small CSS block that
// makes legacy, font-era sites readable on mobile. It is intended for
// archives of 1990s/2000s sites that use <font size="2">, table layouts,
// and no viewport declaration — all of which render as microscopic text on
// a phone. The injected CSS overrides font sizes, loosens line height, caps
// the content width, and hides image-map navigation elements that are
// useless offline.
MobileReadable bool
}
// Report counts what was removed, for the run summary and for tests.
type Report struct {
ScriptsRemoved int
HandlersRemoved int
NoscriptRemoved int
NoscriptUnwrapped int
JSURLsNeutralized int
MetaRefreshRemoved int
DeadLinksRemoved int
CondCommentsRemoved int
CharsetAdded bool
}
// jsURLAttrs are attributes whose value may be a javascript: URL.
var jsURLAttrs = map[string]bool{
"href": true, "src": true, "action": true, "formaction": true,
"poster": true, "data": true, "background": true, "xlink:href": true,
}
// Strip parses doc, removes all JavaScript, and returns the rewritten HTML plus
// a Report. A parse error is returned unchanged to the caller.
func Strip(doc []byte, opts Options) ([]byte, Report, error) {
root, err := html.Parse(bytes.NewReader(doc))
if err != nil {
return nil, Report{}, err
}
rep := CleanTree(root, opts)
var buf bytes.Buffer
if err := html.Render(&buf, root); err != nil {
return nil, rep, err
}
return buf.Bytes(), rep, nil
}
// CleanTree removes all JavaScript from an already-parsed document in place and
// returns the Report. The cloner uses this so the HTML is parsed only once and
// shared with the asset rewriter.
func CleanTree(root *html.Node, opts Options) Report {
var rep Report
clean(root, opts, &rep)
rep.CharsetAdded = ensureCharset(root)
if opts.MobileReadable {
ensureViewport(root)
injectMobileCSS(root)
}
if opts.Banner != "" {
insertBanner(root, opts.Banner)
}
return rep
}
// clean walks n's children, removing or scrubbing each before recursing.
func clean(n *html.Node, opts Options, rep *Report) {
var next *html.Node
for c := n.FirstChild; c != nil; c = next {
next = c.NextSibling
if c.Type == html.CommentNode {
// A downlevel IE conditional comment (<!--[if lt IE 9]>...<![endif]-->)
// parses as one comment whose data holds raw markup — a <script src>
// among it. The element walk never sees that script, so drop the whole
// comment. Downlevel-revealed content lives in sibling nodes, not the
// comment data, so it is untouched.
if isConditionalComment(c.Data) {
n.RemoveChild(c)
rep.CondCommentsRemoved++
}
continue
}
if c.Type == html.ElementNode {
switch c.DataAtom {
case atom.Script:
n.RemoveChild(c)
rep.ScriptsRemoved++
continue
case atom.Noscript:
if opts.KeepNoscript {
unwrapNoscript(n, c)
rep.NoscriptUnwrapped++
} else {
n.RemoveChild(c)
rep.NoscriptRemoved++
}
continue
case atom.Meta:
if isMetaRefresh(c) && (!opts.KeepMetaRefresh || isJSRefresh(c)) {
n.RemoveChild(c)
rep.MetaRefreshRemoved++
continue
}
case atom.Link:
if isDeadLink(c) {
n.RemoveChild(c)
rep.DeadLinksRemoved++
continue
}
}
stripHandlers(c, rep)
neutralizeJSURLs(c, rep)
}
clean(c, opts, rep)
}
}
// stripHandlers removes every on* event-handler attribute from n.
func stripHandlers(n *html.Node, rep *Report) {
kept := n.Attr[:0]
for _, a := range n.Attr {
if len(a.Key) > 2 && strings.HasPrefix(strings.ToLower(a.Key), "on") {
rep.HandlersRemoved++
continue
}
kept = append(kept, a)
}
n.Attr = kept
}
// neutralizeJSURLs replaces javascript: URLs: links become "#", other carriers
// lose the attribute entirely.
func neutralizeJSURLs(n *html.Node, rep *Report) {
kept := n.Attr[:0]
for _, a := range n.Attr {
key := strings.ToLower(a.Key)
if jsURLAttrs[key] && strings.HasPrefix(strings.ToLower(strings.TrimSpace(a.Val)), "javascript:") {
rep.JSURLsNeutralized++
if key == "href" {
a.Val = "#"
kept = append(kept, a)
}
// non-href carriers: drop the attribute.
continue
}
kept = append(kept, a)
}
n.Attr = kept
}
// isMetaRefresh reports whether n is a <meta http-equiv="refresh">.
func isMetaRefresh(n *html.Node) bool {
return strings.EqualFold(attr(n, "http-equiv"), "refresh")
}
// isJSRefresh reports whether a meta-refresh target is a javascript: URL.
func isJSRefresh(n *html.Node) bool {
return strings.Contains(strings.ToLower(attr(n, "content")), "javascript:")
}
// isDeadLink reports whether a <link> is a resource hint that is useless or
// script-bound offline: preconnect, dns-prefetch, modulepreload, or a
// preload/prefetch that targets a script.
func isDeadLink(n *html.Node) bool {
for r := range strings.FieldsSeq(strings.ToLower(attr(n, "rel"))) {
switch r {
case "preconnect", "dns-prefetch", "modulepreload":
return true
case "preload", "prefetch":
as := strings.ToLower(attr(n, "as"))
href := strings.ToLower(attr(n, "href"))
if as == "script" || strings.HasSuffix(href, ".js") {
return true
}
}
}
return false
}
// isConditionalComment reports whether a comment's data is a downlevel IE
// conditional-comment marker. Both the downlevel-hidden form (the whole
// "[if lt IE 9]>...<![endif]" in one comment) and the two markers of the
// downlevel-revealed form ("[if gte IE 9]><!" and "<![endif]") match, so the
// markers are stripped while any revealed content, which sits in sibling
// nodes, stays.
func isConditionalComment(data string) bool {
d := strings.TrimSpace(data)
return strings.HasPrefix(d, "[if") ||
strings.HasPrefix(d, "<![endif]") ||
strings.HasPrefix(d, "[endif]")
}
// unwrapNoscript replaces a <noscript> with its content. Because x/net/html
// parses noscript content as raw text (scripting enabled), the text is
// re-parsed as a fragment in the parent's context and spliced in before the
// noscript node, which is then removed.
func unwrapNoscript(parent, ns *html.Node) {
var raw strings.Builder
for c := ns.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.TextNode {
raw.WriteString(c.Data)
}
}
frag, err := html.ParseFragment(strings.NewReader(raw.String()), &html.Node{
Type: html.ElementNode,
Data: "body",
DataAtom: atom.Body,
})
if err == nil {
for _, fn := range frag {
parent.InsertBefore(fn, ns)
}
}
parent.RemoveChild(ns)
}
// ensureCharset guarantees the document declares UTF-8, inserting a
// <meta charset="utf-8"> at the top of <head> when none is present, and reports
// whether it added one. kage renders every saved page as UTF-8, but a source
// that set its charset only in the HTTP Content-Type header, with no <meta>
// charset in the markup, loses that signal once the page is a standalone file.
// A reader then serving the bytes without a charset falls back to its locale
// encoding and mojibakes every multibyte character (curly quotes, dashes, a
// non-breaking space). Declaring the charset in the markup makes the page
// self-describing in any reader, kage's own viewer and Kiwix alike.
func ensureCharset(root *html.Node) bool {
head := findElement(root, atom.Head)
if head == nil {
return false
}
if hasCharsetMeta(head) {
return false
}
meta := &html.Node{
Type: html.ElementNode,
Data: "meta",
DataAtom: atom.Meta,
Attr: []html.Attribute{{Key: "charset", Val: "utf-8"}},
}
// The declaration must precede any content for a reader to honour it, so it
// goes first in <head>.
head.InsertBefore(meta, head.FirstChild)
return true
}
// hasCharsetMeta reports whether head already declares a character encoding,
// either as <meta charset="..."> or the older <meta http-equiv="Content-Type"
// content="...; charset=...">.
func hasCharsetMeta(head *html.Node) bool {
for c := head.FirstChild; c != nil; c = c.NextSibling {
if c.Type != html.ElementNode || c.DataAtom != atom.Meta {
continue
}
if attr(c, "charset") != "" {
return true
}
if strings.EqualFold(attr(c, "http-equiv"), "content-type") &&
strings.Contains(strings.ToLower(attr(c, "content")), "charset=") {
return true
}
}
return false
}
// findElement returns the first element node of the given atom in document
// order, or nil if none exists.
func findElement(n *html.Node, a atom.Atom) *html.Node {
if n.Type == html.ElementNode && n.DataAtom == a {
return n
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
if found := findElement(c, a); found != nil {
return found
}
}
return nil
}
// mobileCSS is injected when MobileReadable is set. It rewrites font-era
// HTML for comfortable reading on a phone. Key rules:
//
// - box-sizing:border-box — makes padding predictable in a layout built with
// HTML width attributes, so our padding doesn't cause overflow.
// - body — no fixed max-width here; let the table rules handle width instead.
// overflow-x:hidden catches any stray overflow without a scrollbar.
// - font element — overrides in-HTML size/face attributes (e.g. <font size="2">).
// - [width],[height] — cancels all HTML attribute widths/heights on every
// element (tables, tds, imgs, etc.) so fixed-pixel columns become fluid.
// - table — fluid, auto layout, no horizontal scroll.
// - td — auto width so a three-column table (nav | spacer | content) collapses
// to one usable column once the nav td is hidden.
// - img — responsive: never wider than its container.
// - td:has(>img[usemap]) — hides the entire nav column td, not just the image
// inside it; hiding only the img left a tall empty white box.
// - td:has(>img[src*="trans_1x1"]) — hides the 26 px spacer column td (a 1×1
// transparent GIF whose only job was spacing in the original table layout).
const mobileCSS = `*{box-sizing:border-box}` +
`:root{font-size:18px}` +
`body{margin:0;padding:.75em 1em;line-height:1.7;font-family:Georgia,"Times New Roman",serif;overflow-x:hidden}` +
`font{font-size:1rem!important;font-family:inherit!important;color:inherit!important}` +
`[width]{width:auto!important;max-width:100%!important}` +
`[height]{height:auto!important}` +
`table{width:100%!important;max-width:100%!important;table-layout:auto!important;border-collapse:collapse!important;word-break:break-word}` +
`td,th{width:auto!important;max-width:100%!important;padding:.35em .5em!important;vertical-align:top!important;overflow-wrap:break-word}` +
`img{max-width:100%!important;height:auto!important}` +
`img[usemap],map{display:none!important}` +
`td:has(>img[usemap]),td:has(>map){display:none!important}` +
`img[src*="trans_1x1"],img[src*="spacer"],img[height="1"],img[width="1"]{display:none!important}` +
`td:has(>img[src*="trans_1x1"]:only-child),td:has(>img[height="1"]:only-child){display:none!important}`
// ensureViewport inserts <meta name="viewport" content="width=device-width,
// initial-scale=1"> at the top of <head> when the document does not already
// carry one. Without it a mobile browser shrinks the page to fit the screen
// at desktop scale, making text unreadably small regardless of CSS font sizes.
func ensureViewport(root *html.Node) {
head := findElement(root, atom.Head)
if head == nil {
return
}
// Check whether a viewport meta already exists.
for c := head.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.ElementNode && c.DataAtom == atom.Meta &&
strings.EqualFold(attr(c, "name"), "viewport") {
return
}
}
meta := &html.Node{
Type: html.ElementNode,
Data: "meta",
DataAtom: atom.Meta,
Attr: []html.Attribute{
{Key: "name", Val: "viewport"},
{Key: "content", Val: "width=device-width, initial-scale=1"},
},
}
head.InsertBefore(meta, head.FirstChild)
}
// injectMobileCSS appends a <style> block containing mobileCSS to <head>.
// It goes at the end of <head> so it wins specificity ties over any existing
// inline styles the page already carries.
func injectMobileCSS(root *html.Node) {
head := findElement(root, atom.Head)
if head == nil {
return
}
style := &html.Node{
Type: html.ElementNode,
Data: "style",
DataAtom: atom.Style,
}
style.AppendChild(&html.Node{Type: html.TextNode, Data: mobileCSS})
head.AppendChild(style)
}
// insertBanner prepends an HTML comment to the document.
func insertBanner(root *html.Node, text string) {
c := &html.Node{Type: html.CommentNode, Data: " " + text + " "}
if root.FirstChild != nil {
root.InsertBefore(c, root.FirstChild)
} else {
root.AppendChild(c)
}
}
// attr returns the value of n's attribute key (case-insensitive), or "".
func attr(n *html.Node, key string) string {
for _, a := range n.Attr {
if strings.EqualFold(a.Key, key) {
return a.Val
}
}
return ""
}