Skip to content

Keep the doctype so saved pages are not quirks mode (issue #16) - #78

Merged
tamnd merged 1 commit into
mainfrom
fix/16-preserve-doctype
Aug 6, 2026
Merged

Keep the doctype so saved pages are not quirks mode (issue #16)#78
tamnd merged 1 commit into
mainfrom
fix/16-preserve-doctype

Conversation

@tamnd

@tamnd tamnd commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Fixes #16.

The report

It seems to default to an encoding other than UTF-8 (which is the current default, and mandatory for HTML5). In the browser I can press a button to change the encoding, but I am afraid that won't work with webview.

No URL and no saved file, so this sat unanswered. There is a real bug behind it, and it is not quite the one the title names.

What is actually wrong

browser.Render serialises a page with page.HTML(), which is the outerHTML of the <html> element. A doctype is a sibling of <html>, not a child, so it is never in that string. html.Parse then builds a tree with no DoctypeNode and html.Render writes no doctype. Every page kage has ever saved comes out without one.

A document with no doctype is quirks mode in every browser, and that produces the reported symptom directly. In standards mode a <meta charset> is authoritative. In quirks mode a browser is readier to fall back to its locale default, which is windows-1252 in Western locales, so the text mojibakes and the user has to pick the encoding by hand. The reporter's worry about webview is well founded: a webview or an e-reader has no encoding menu, so there is no way back. For a tool whose output is meant to be read in Kiwix on a plane, that matters.

There is a second symptom nobody reported. Quirks mode reverts the box model to the pre-CSS2 IE one and changes line-height, table cell inheritance and vertical-align, so every saved page also lays out differently from the original.

The fix

Read document.doctype after the render and put it back at the top of the serialised page.

The doctype is reproduced exactly rather than replaced with a flat <!DOCTYPE html>, because the string itself selects the rendering mode. HTML 4.01 Transitional is standards mode with its system identifier and quirks mode without it, and those old sites are exactly the audience for --mobile-readable. Reproducing it means no page changes rendering mode in either direction: a page that was standards mode live is standards mode offline, and a page that genuinely had no doctype stays quirks so it keeps rendering the way its author saw it.

The parts arrive from an untrusted page and land at the very top of a file we write, so the source form is assembled in Go rather than taken from the page's own XMLSerializer. x/net/html quotes the identifiers but does not escape a quote inside one, and it writes the name verbatim, so anything that could close the token early or carry markup is dropped rather than escaped.

One thing in sanitize: insertBanner put the cloned by kage comment at root.FirstChild, which with a doctype present now means ahead of it. Modern browsers still read a doctype that follows a comment, but older ones and several offline readers do not, so the banner now goes after it.

Verified

TestRenderPreservesDoctype drives real Chrome over three pages, HTML5, HTML 4.01 Transitional and no doctype, and checks each comes back the way it went in. TestRenderDoctype covers the assembly and the rejection cases without Chrome. sanitize gets two tests: a doctype survives Strip with the banner behind it, and sanitize never invents one.

End to end on a local site:

$ kage clone http://127.0.0.1:8731/index.html -o out
$ head -c 120 out/127.0.0.1/index.html
<!DOCTYPE html><!-- cloned by kage from http://127.0.0.1:8731/index.html --><html><head><meta charset="utf-8"/>

$ head -c 130 out/127.0.0.1/legacy.html/index.html
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd"><!-- cloned by kage from

go build ./..., go vet ./... and go test ./... all pass with Chrome present.

Not in scope

#16 has a second half that #74 already covers: a page declaring <meta charset="iso-8859-1"> keeps that declaration even though kage writes UTF-8 bytes. The two fixes are independent and go well together, since #74 corrects the meta and this makes the meta authoritative again. I left it out to keep the two reviewable separately.

kage serialises a rendered page as the outerHTML of <html>. A doctype is a
sibling of <html> rather than a child, so it was never in that string and
every page kage has ever written came out without one.

A document with no doctype is quirks mode in every browser. The box model
reverts to the pre-CSS2 IE one and line-height, table cell inheritance and
vertical-align all change, so the saved copy lays out differently from the
original. It also weakens the <meta charset> that ensureCharset guarantees:
in standards mode that declaration is authoritative, while in quirks mode a
browser is readier to fall back to its locale encoding and mojibake the text.
That is the symptom reported in issue #16, and a webview or e-reader with no
encoding menu has no way back from it.

Read the doctype from the DOM after the render and put it back. It is
reproduced exactly rather than replaced with <!DOCTYPE html>, because the
string itself selects the rendering mode: HTML 4.01 Transitional is standards
mode with its system identifier and quirks mode without it. A page that
genuinely had no doctype on the live web still gets none, so it keeps
rendering the way its author saw it.

The parts come from an untrusted page and land at the top of a file we write,
so the source form is assembled in Go and anything that could close the token
early or carry markup is dropped. The banner comment now goes after the
doctype so the doctype is the first thing in the file.
@tamnd
tamnd merged commit 344d076 into main Aug 6, 2026
9 checks passed
@tamnd
tamnd deleted the fix/16-preserve-doctype branch August 6, 2026 03:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Does not work well for UTF-8 or HTML5

1 participant