diff --git a/CHANGELOG.md b/CHANGELOG.md index d8a80a6d..639db696 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,32 @@ because it turns other people's test suites red. ## [Unreleased] +### Breaking + +- **An HTML page with no label has a title that says Page.** The smallest + page and every page written with `--clean` carried an empty title, which + the HTML standard does not allow and the Nu HTML Checker reports as an + error. They now carry `Page`, a word that says nothing + about this tool, since `--clean` is there to keep it out of the file. + Pages with a label are unchanged. The smallest HTML page grows from 118 B + to 122 B, so 118 B to 121 B are now refused, and `tfg preset eject + empty-and-minimal` prints 122 where it printed 118 - a manifest from a run + of that preset records a different `recipe_hash`. +- **The catalogue of every element renames its forms section and its hidden + field.** With `elements=every` the forms section is `id="tfg-forms"` and + the hidden field is `name="tfg-hidden"`, the way the list of links has + always been `tfg-links`. DOMPurify removes an id or a name that shadows a + property of the document, so after sanitising, the two links to the forms + section led nowhere and the hidden field left a submitted form without its + name. The smallest catalogue grows from 6924 B to 6944 B. +- **A newsletter's page says its language.** With `layout=newsletter` the + HTML part opens with ``, as the pages of the `html` format + do, and the line in Polish and Japanese marks each half with its own + language, `` and ``, so a screen reader + does not read them as English. The smallest newsletter grows by 10 B in + 7bit, and by 57 B to 78 B with 8bit, quoted-printable and base64, which + carry that line. A message laid out `simple` is unchanged. + ### Added - **Films: the `webm` format.** A WebM video with an AV1 picture and no @@ -312,6 +338,13 @@ because it turns other people's test suites red. ### Changed +- **The `eml` settings say what Thunderbird and MimeKit do with them.** + Measured with Thunderbird 157.0.1 and MimeKit 4.18.1. `charset` says that + MimeKit with no code page provider tries UTF-8 and then Latin-1, and reads + Latin-2 and Windows-1250 right once one is registered. `message_depth` says that Thunderbird shows 32 levels in full, + and past that shows only the headers of the 32nd message inside, without a + word. `filename_style` says that Thunderbird calls an attachment with no + name Part 1.2, Part 1.3 and so on. - **`empty-and-minimal` now includes the films, GeoJSON and mail.** The preset builds the smallest file of every format this build has, so with `formats` left at `all` it gains a `webm`, an `mp4`, a `geojson` and an `eml` target, and the recipe diff --git a/internal/format/eml/message.go b/internal/format/eml/message.go index dfc2e32c..8a662ad7 100644 --- a/internal/format/eml/message.go +++ b/internal/format/eml/message.go @@ -47,7 +47,7 @@ func newMessage(s settings, seed uint64, children, pics []child) *message { return &message{ s: s, id: id, children: children, pictures: pics, seed: seed, plainLine: s.internationalLine("", ""), - htmlLine: s.internationalLine("

", "

"), + htmlLine: s.htmlInternationalLine(), images: imageLines(s, pics), frame: newFrame(s, id), } @@ -189,7 +189,7 @@ func (m *message) plain(o *out) { // the pictures before them, in its frame. func (m *message) html(o *out) { eol := o.eol - o.put(m.frame.htmlBefore, "") + o.put(m.frame.htmlBefore, m.frame.htmlTag) if m.parity { o.put(" ") } diff --git a/internal/format/eml/newsletter.go b/internal/format/eml/newsletter.go index f356edc1..f6ecd019 100644 --- a/internal/format/eml/newsletter.go +++ b/internal/format/eml/newsletter.go @@ -44,15 +44,15 @@ type frame struct { headers string // plainOpen and plainClose stand before and after the plain text. plainOpen, plainClose string - // htmlBefore stands before , htmlOpen after it and htmlClose after - // the paragraph of words. - htmlBefore, htmlOpen, htmlClose string + // htmlBefore stands before the html element's start tag, htmlTag is that + // tag, htmlOpen stands after it and htmlClose after the paragraph of words. + htmlBefore, htmlTag, htmlOpen, htmlClose string } // simpleFrame is the frame of a message laid out simple: the body element and // nothing else, the bytes this format has always written. func simpleFrame(s settings) frame { - return frame{htmlOpen: "" + s.textEOL(), htmlClose: ""} + return frame{htmlTag: "", htmlOpen: "" + s.textEOL(), htmlClose: ""} } // newsletterFrame is the frame of a newsletter, unsubscribe being the https @@ -67,7 +67,13 @@ func newsletterFrame(s settings, unsubscribe string) frame { "You receive this because you subscribed to "+newsletterName+".") + frameLine(s, "Unsubscribe ("+unsubscribe+")"), htmlBefore: frameLines(s, ""), - htmlOpen: frameLines(s, newsletterHead...), + // The page says its language, as the pages of the html format do. The + // Nu HTML Checker asks for it, and a screen reader reads the page in + // the voice it names (until 2026-10-15 the newsletter had none). In the + // frame rather than in the writer, so quoted-printable writes its = as + // =3D like every other attribute. + htmlTag: frameLine(s, ``), + htmlOpen: frameLines(s, newsletterHead...), htmlClose: frameLines(s, newsletterFoot(unsubscribe)...) + frameLine(s, ""), } diff --git a/internal/format/eml/settings.go b/internal/format/eml/settings.go index 5c07d09a..93417c5c 100644 --- a/internal/format/eml/settings.go +++ b/internal/format/eml/settings.go @@ -282,7 +282,7 @@ func charsetProperty() format.Property { Default: CharsetUTF8, Detail: "The character set of the text when text_encoding is not 7bit. iso-8859-2 and windows-1250 hold Polish and no Japanese, " + "and they write ą, ś and ź as different bytes, so a program that takes one for the other shows the wrong letters. " + - "MimeKit with no code page provider registered reads both as Latin-1, and Go's standard library converts no character set at all. " + + "MimeKit with no code page provider registered tries UTF-8 and then Latin-1, which shows the Polish letters of both wrong, and reads them right once one is. Go's standard library converts no character set at all. " + "With 7bit the text is plain ASCII and says us-ascii, whatever this is set to.", } } @@ -341,7 +341,7 @@ func filenameStyle() format.Property { Detail: "How the names of attached files and pictures are written. rfc2231 is the standard way. " + "rfc2047 is the encoded form RFC 2047 forbids inside a name, and many readers decode it anyway. " + "both writes the two, and content-type puts the name only where old mail programs looked for it. " + - "none writes no name at all, and a program that needs one has to make one up. " + + "none writes no name at all, and a program that needs one has to make one up - Thunderbird 157 calls them Part 1.2, Part 1.3 and so on. " + "Go's standard library reads no name from content-type and the encoded text itself from rfc2047. " + "Unless headers is encoded a name is written as it is and only where it goes changes, " + "so rfc2047 and both write the same bytes.", @@ -357,7 +357,8 @@ func depthProperty() format.Property { Default: "1", Detail: "How many messages deep the message goes. 1 is a message alone. 2 carries a forwarded message as an attachment, " + "and each step adds one more inside the last, the smallest message each can be. " + - "Python 3.14's email package runs out of recursion at around 490, and MimeKit stops taking them apart past 513 without a word.", + "Python 3.14's email package runs out of recursion at around 490, and MimeKit stops taking them apart past 513 without a word. " + + "Thunderbird 157 shows up to 32 in full. Past that the 32nd message inside shows only its headers and nothing under it appears, without a word.", Group: attachmentsGroup, } } diff --git a/internal/format/eml/text.go b/internal/format/eml/text.go index 9db32a69..ef3f0b78 100644 --- a/internal/format/eml/text.go +++ b/internal/format/eml/text.go @@ -30,7 +30,8 @@ const ( // Windows-1250 write as different bytes. const ( polish = "Zażółć gęślą jaźń." - international = polish + " 日本語のテキストです。" + japanese = "日本語のテキストです。" + international = polish + " " + japanese ) // singleByte is how ISO-8859-2 and Windows-1250 write the letters of the @@ -97,7 +98,36 @@ func (s settings) internationalLine(open, close string) string { if !s.international() { return "" } - line := open + s.encodedLine() + close + return s.textLine(open + s.encodedLine() + close) +} + +// htmlInternationalLine is the line as the HTML part writes it. A newsletter's +// page says it is English (), so there each half says which +// language it is in a span of its own - the Polish half, and the Japanese one +// where the character set holds it - or a screen reader reads both as English +// (review of #190, as the html format marks its paragraphs in other scripts). +// A message laid out simple says no language anywhere and keeps the bytes it +// always wrote. +func (s settings) htmlInternationalLine() string { + if s.layout != Newsletter { + return s.internationalLine("

", "

") + } + if !s.international() { + return "" + } + // Two lines rather than one where the Japanese half comes: the spans make + // the line 83 characters, past the 78 RFC 5322 asks for, and 8bit has no + // soft line break to keep it under (measured by the structural checker). + // A line break inside a paragraph is white space, as the space it replaces. + if _, single := singleByte[s.charset]; single { + return s.textLine(`

` + s.encodedLine() + `

`) + } + return s.textLine(`

`+polish+``) + s.textLine(``+japanese+`

`) +} + +// textLine is one line of the text in the encoding the text is written in, +// with the text's line ending. +func (s settings) textLine(line string) string { if s.encoding == QuotedPrintable { line = quotedPrintable(line, s.eol) } diff --git a/internal/format/htmlfile/catalogue.go b/internal/format/htmlfile/catalogue.go index acc37e0e..cc11b854 100644 --- a/internal/format/htmlfile/catalogue.go +++ b/internal/format/htmlfile/catalogue.go @@ -42,15 +42,22 @@ const ( `` + "\n" + "\n" + // catalogueBody is the elements themselves. The forms article is + // tfg-forms rather than forms for the reason the links list is tfg-links: + // an id that shadows a document property is taken out by DOMPurify 3.4.17, + // and the two links to it then lead nowhere (O282, until 2026-10-15). The + // hidden field is named tfg-hidden for the same reason - a name hidden + // shadows document.hidden, DOMPurify took it out, and the field left a + // submitted form without one (measured 2026-10-15). catalogueBody = `

Every element

Every element of the HTML standard, each one at least once.

- +

Text

-

Plain link, HTML, bold, name, reversed, a title, code(), forty two, removed, term, stressed, alternate, added, Ctrl, marked, quoted, no longer accurate, output, fine print, span, important, H2O, x2, , annotated, n, longword, a line
break.

+

Plain link, HTML, bold, name, reversed, a title, code(), forty two, removed, term, stressed, alternate, added, Ctrl, marked, quoted, no longer accurate, output, fine print, span, important, H2O, x2, , annotated, n, longword, a line
break.

base(annotation) and x=2 and slot fallback and a custom element.

Third level

Fourth level

@@ -78,12 +85,12 @@ const ( footer row
-
+

Forms

Every type of input - + diff --git a/internal/format/htmlfile/html.go b/internal/format/htmlfile/html.go index 6ffd7aa9..b5b594e1 100644 --- a/internal/format/htmlfile/html.go +++ b/internal/format/htmlfile/html.go @@ -195,7 +195,7 @@ func labelledHead(s settings, r format.Request, min int64) (head, heading string // does - one for the tab and one for the reader. A fragment has no head // to put a title in, so it carries only the heading, and the label is // still visible because a heading is. - head = strings.Replace(plain, emptyTitle, ""+label+"", 1) + head = strings.Replace(plain, placeholderTitle, ""+label+"", 1) extra = int64(len(head) - len(plain)) } if extra+int64(len(heading))+min <= r.Bytes { diff --git a/internal/format/htmlfile/markup.go b/internal/format/htmlfile/markup.go index edbfe166..538b9e53 100644 --- a/internal/format/htmlfile/markup.go +++ b/internal/format/htmlfile/markup.go @@ -78,14 +78,15 @@ const ( // plain is how a page has been spelled since 2026-08-01, the golden hashes // holding it there. The skeleton is in two halves so the catalogue and the -// style sheet can go into the head between them, and the title is empty until a -// label fills it - the empty pair is what the smallest document carries. +// style sheet can go into the head between them, and the title says Page until +// a label fills it - what the smallest document and every --clean one carry +// (an empty title until 2026-10-15, O277). var plain = markup{ headOpen: `` + "\n" + `` + "\n" + `` + "\n" + `` + "\n" + - emptyTitle + "\n", + placeholderTitle + "\n", headClose: `` + "\n" + `` + "\n", bodyClose: "\n\n", diff --git a/internal/format/htmlfile/page.go b/internal/format/htmlfile/page.go index e80dc9a2..d69abdcf 100644 --- a/internal/format/htmlfile/page.go +++ b/internal/format/htmlfile/page.go @@ -9,8 +9,13 @@ package htmlfile // The markup itself - the skeleton, the label, the levels and the blocks - is // spelled in markup.go, in every layout and every way of carrying styles. -// emptyTitle is the title a page carries until a label fills it in. -const emptyTitle = `` +// placeholderTitle is the title a page carries when no label fills it in: at +// the smallest sizes, and in every page under --clean. Not empty, because the +// standard asks a title for text and the Nu HTML Checker calls an empty one an +// error (O277). A word that says nothing about this tool, because --clean is +// there to keep the tool's description out of the file +// (docs/HTML-6-2026-10-15.md). +const placeholderTitle = `Page` // byteOrderMark is the UTF-8 mark, the only one a page may carry: the standard // requires UTF-8 (docs/HTML-STRUCTURE-2026-09-08.md section 1). Written as its diff --git a/internal/guard/emlnewsletter_test.go b/internal/guard/emlnewsletter_test.go index 07f0c3a1..1df5fad5 100644 --- a/internal/guard/emlnewsletter_test.go +++ b/internal/guard/emlnewsletter_test.go @@ -135,6 +135,16 @@ func TestTheEMLCheckRefusesANewsletterBrokenOnPurpose(t *testing.T) { ".frame { width: 600px; background: #ffffff; }\r\n"+ ".button { background: #1a73e8; color: #ffffff; text-decoration: none; }\r\n", "