From 82621e65f34f233826711f2d6130b10ec6dc2ce2 Mon Sep 17 00:00:00 2001
From: DonislawDev
Date: Wed, 14 Oct 2026 16:23:50 +0200
Subject: [PATCH 1/3] html: a page on one line, and styles in a sheet or in
attributes
formatting=minified puts the whole page on one line with no newline at
the end, and it shows the same as the page in lines. The catalogue of
every element keeps a space where a line ended, and its pre keeps its
line break as a character reference. The manifest says line_ending none.
styles=embedded puts one list of CSS rules in a style element, in the
head of a page and at the very start of a fragment. styles=inline puts
the same rules in a style attribute on every element they are for,
outside the catalogue. No rule names anything to fetch.
Every piece of markup a page writes is now one entry of an array, and
the other spellings are made from the plain one by two rules, so the
default bytes are unchanged. The structural check is told both settings
and asserts them, four new guards, three golden hashes.
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 19 +-
README.md | 2 +-
internal/format/htmlfile/blocks.go | 43 +--
internal/format/htmlfile/catalogue.go | 12 +-
internal/format/htmlfile/characters.go | 5 +-
internal/format/htmlfile/html.go | 25 +-
internal/format/htmlfile/markup.go | 265 ++++++++++++++++++
internal/format/htmlfile/page.go | 65 ++---
internal/format/htmlfile/pictures.go | 30 +-
internal/format/htmlfile/settings.go | 65 ++++-
internal/guard/generatorbytes_test.go | 11 +
internal/guard/htmlsettings_test.go | 49 +++-
internal/guard/htmlstyles_test.go | 224 +++++++++++++++
internal/guard/parity_test.go | 2 +
internal/guard/testdata/generator-golden.json | 12 +
internal/gui/text/locale/registry/en.json | 39 ++-
internal/gui/text/locale/registry/pl.json | 9 +-
internal/gui/text/locale/said/en.json | 16 +-
internal/gui/text/locale/said/pl.json | 4 +-
internal/oracle/strict.py | 119 ++++++--
20 files changed, 890 insertions(+), 126 deletions(-)
create mode 100644 internal/format/htmlfile/markup.go
create mode 100644 internal/guard/htmlstyles_test.go
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6f814876..d5364767 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -160,7 +160,7 @@ because it turns other people's test suites red.
- **HTML: pictures inside the page.** `inline`, `inline_format` and
`inline_size` - the same settings, with the same meaning, as the pictures
of an `eml` message - write real pictures of any picture format into the
- page as `data:` URLs, each on a line of its own above the blocks, so
+ page as `data:` URLs, each in a paragraph of its own above the blocks, so
opening the page fetches nothing. Each picture is the very file its format
writes on its own. The Nu HTML Checker finds nothing wrong with a page of
any of the nine formats, Chromium shows every format but `tiff`, and
@@ -168,6 +168,23 @@ because it turns other people's test suites red.
a picture past about 7.1 MB unless told to read huge trees - on Linux lxml
took over 20 seconds over such a page, and with a 15 MB picture it lost the
rest of the page as well. Files made without pictures keep their bytes.
+- **HTML: a page on one line, and styles.** `formatting=minified` puts the
+ whole page on one line with no newline at the end, the way a bundler
+ writes it, and the page shows the same as one written in lines. A program
+ reading line by line gets the whole page as one line - Go's
+ `bufio.Scanner` refuses a line past 64 KB unless given a larger buffer -
+ and text read by joining what the elements hold runs the last word of one
+ block into the first word of the next. The manifest says `line_ending:
+ none` for such a page. `styles=embedded` puts CSS rules in a style element,
+ in the head of a page and at the very start of a fragment, and
+ `styles=inline` puts the same rules in a style attribute on every heading,
+ paragraph, list, table and quote the page is filled with. The Nu HTML
+ Checker finds nothing wrong with either, and Chromium shows both the same.
+ DOMPurify with its defaults drops the style element without listing it as
+ removed and keeps the attributes, and lxml's `text_content` and
+ BeautifulSoup on html5lib count the rules as text of the page. No rule
+ names anything to fetch. Files made with the earlier settings keep their
+ bytes.
- **A length of time as a setting.** `duration`, `change_interval` and `keyframe_interval` take
`10s`, `1m30s`, `1h`, `500ms` or `59.9s`, to the millisecond. A bare number
is refused rather than read as seconds, and so is a length that does not
diff --git a/README.md b/README.md
index 2acef44b..f3c68a64 100644
--- a/README.md
+++ b/README.md
@@ -621,7 +621,7 @@ recipe. `tfg formats ` prints the allowed range or list for each:
| `geojson` | `geometry`, `formatting`, `precision`, `altitude`, `vertices`, `holes`, `winding`, `antimeridian`, `unlocated`, `ids`, `bbox` |
| `eml` | `body`, `text_encoding`, `charset`, `headers`, `recipients`, `line_endings`, `attachments`, `attachment_format`, `attachment_size`, `filename_style`, `message_depth`, `inline`, `inline_format`, `inline_size` |
| `svg` | `width`, `height` |
-| `html` | `structure`, `nesting`, `characters`, `elements`, `bom`, `inline`, `inline_format`, `inline_size` |
+| `html` | `structure`, `nesting`, `characters`, `elements`, `bom`, `formatting`, `styles`, `inline`, `inline_format`, `inline_size` |
| `yaml`, `toml` | none in this build - the document is a fixed shape, and a size is the only thing to ask for |
```
diff --git a/internal/format/htmlfile/blocks.go b/internal/format/htmlfile/blocks.go
index 3b797a7f..799e3cde 100644
--- a/internal/format/htmlfile/blocks.go
+++ b/internal/format/htmlfile/blocks.go
@@ -25,51 +25,56 @@ type blocks struct {
// tail is what the closing record ends with, which is the only thing the
// shape of the file changes down here.
tail string
+ // m is how the blocks are spelled. The words drawn are the same in every
+ // spelling, so one seed fills a page of one line and a page of many with
+ // the same blocks.
+ m *markup
}
// Shortest is the smallest closing record: an empty paragraph plus whatever
// this shape closes after it.
-func (b blocks) Shortest() int64 { return int64(len(paraOpen) + len(b.tail)) }
+func (b blocks) Shortest() int64 { return int64(len(b.m[paraOpen]) + len(b.tail)) }
-func (blocks) Append(dst []byte, rng *rand.Rand) []byte {
+func (b blocks) Append(dst []byte, rng *rand.Rand) []byte {
+ m := b.m
switch rng.IntN(5) {
case 0:
- dst = append(dst, ""...)
+ dst = append(dst, m[h2Open]...)
dst = appendPhrase(dst, rng, 2+rng.IntN(3))
- return append(dst, "
\n"...)
+ return append(dst, m[h2Close]...)
case 1:
- dst = append(dst, "\n"...)
+ dst = append(dst, m[ulOpen]...)
for i, n := 0, 2+rng.IntN(3); i < n; i++ {
- dst = append(dst, "- "...)
+ dst = append(dst, m[liOpen]...)
dst = appendPhrase(dst, rng, 3+rng.IntN(4))
- dst = append(dst, "
\n"...)
+ dst = append(dst, m[liClose]...)
}
- return append(dst, "
\n"...)
+ return append(dst, m[ulClose]...)
case 2:
- dst = append(dst, "\n| id | field | value |
\n"...)
+ dst = append(dst, m[tableOpen]...)
for i, n := 0, 2+rng.IntN(3); i < n; i++ {
- dst = append(dst, "| "...)
+ dst = append(dst, m[rowOpen]...)
dst = strconv.AppendInt(dst, int64(i+1), 10)
- dst = append(dst, " | "...)
+ dst = append(dst, m[cellSep]...)
dst = append(dst, words[rng.IntN(len(words))]...)
- dst = append(dst, " | "...)
+ dst = append(dst, m[cellSep]...)
dst = append(dst, words[rng.IntN(len(words))]...)
- dst = append(dst, " |
\n"...)
+ dst = append(dst, m[rowClose]...)
}
- return append(dst, "
\n"...)
+ return append(dst, m[tableClose]...)
case 3:
// A real page carries entities, and an ampersand left raw is the
// classic way a document stops being well formed. The fixture carries
// one on purpose.
- dst = append(dst, ""...)
+ dst = append(dst, m[quoteOpen]...)
dst = appendPhrase(dst, rng, 4+rng.IntN(4))
dst = append(dst, " & "...)
dst = appendPhrase(dst, rng, 2)
- return append(dst, "
\n"...)
+ return append(dst, m[quoteClose]...)
default:
- dst = append(dst, paraOpen...)
+ dst = append(dst, m[paraOpen]...)
dst = appendPhrase(dst, rng, 12+rng.IntN(12))
- return append(dst, paraClose...)
+ return append(dst, m[paraClose]...)
}
}
@@ -79,7 +84,7 @@ func (blocks) Discard() {}
func (b blocks) AppendExact(dst []byte, rng *rand.Rand, n int64) []byte {
start := len(dst)
- dst = append(dst, paraOpen...)
+ dst = append(dst, b.m[paraOpen]...)
used := int64(len(dst)-start) + int64(len(b.tail))
dst = appendFiller(dst, n-used)
return append(dst, b.tail...)
diff --git a/internal/format/htmlfile/catalogue.go b/internal/format/htmlfile/catalogue.go
index 67950605..b8c24f7e 100644
--- a/internal/format/htmlfile/catalogue.go
+++ b/internal/format/htmlfile/catalogue.go
@@ -29,6 +29,11 @@ const (
smallPicture = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAAEUlEQVR42mNwaDiAFTEMLQkAYvNgAQlPC90AAAAASUVORK5CYII="
sound = "data:audio/wav;base64,UklGRjQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YRAAAACAgICAgICAgICAgICAgICA"
+ // preformatted is the text of the pre element, the one place in the
+ // catalogue where a line break is text rather than layout. A page on one
+ // line has to keep it, so it is named here and found by name.
+ preformatted = "preformatted text\n keeps its spaces"
+
// catalogueHead is what the catalogue puts in the head of a page. base
// names no address, so it changes nothing a link resolves to, and the icon
// is empty data so a browser asks nobody for one.
@@ -52,8 +57,7 @@ const (
Fifth level
Sixth level
A quotation in a block.
-preformatted text
- keeps its spaces
+` + preformatted + `
- Term
- Its description.
- three
- two
@@ -143,7 +147,7 @@ func (s settings) catalogueHeadFor() string {
if !s.every || s.shape != structureDocument {
return ""
}
- return catalogueHead
+ return s.spelling()[headCatalogue]
}
// catalogueBlocks is what the catalogue adds to the body.
@@ -151,5 +155,5 @@ func (s settings) catalogueBlocks() string {
if !s.every {
return ""
}
- return catalogueBody
+ return s.spelling()[bodyCatalogue]
}
diff --git a/internal/format/htmlfile/characters.go b/internal/format/htmlfile/characters.go
index 5520a8a4..67ce624d 100644
--- a/internal/format/htmlfile/characters.go
+++ b/internal/format/htmlfile/characters.go
@@ -29,10 +29,11 @@ const (
`and entities & < > " © € € 😀.
` + "\n"
)
-// internationalBlocks is what the setting adds, nothing for ascii.
+// internationalBlocks is what the setting adds, nothing for ascii, in the
+// spelling of the page.
func (s settings) internationalBlocks() string {
if !s.international {
return ""
}
- return international
+ return s.spelling()[scripts]
}
diff --git a/internal/format/htmlfile/html.go b/internal/format/htmlfile/html.go
index c6efe840..b3a0d4cb 100644
--- a/internal/format/htmlfile/html.go
+++ b/internal/format/htmlfile/html.go
@@ -56,10 +56,9 @@ func init() {
// than a comment.
Label: format.LabelVisible,
Oracle: "python-html",
- // Style sheets and a page on one line come later
- // (docs/HTML-2-2026-10-14.md section 8). Declaring only what is here
- // is what makes a recipe asking for them fail loudly instead of
- // quietly producing something else.
+ // Links come later, if at all (docs/HTML-2-2026-10-14.md section 3,
+ // B4). Declaring only what is here is what makes a recipe asking for
+ // them fail loudly instead of quietly producing something else.
Properties: properties(),
GeneratorVersion: generatorVersion,
Generator: generator{},
@@ -89,7 +88,7 @@ func (generator) PlanContext(ctx context.Context, r format.Request) (format.Plan
return format.Plan{}, err
}
- pictures, pictureBytes, err := planPictures(ctx, r, s.pictures)
+ pictures, pictureBytes, err := planPictures(ctx, r, s.pictures, s.spelling())
if err != nil {
return format.Plan{}, err
}
@@ -110,8 +109,10 @@ func (generator) PlanContext(ctx context.Context, r format.Request) (format.Plan
Exact: true,
Determinism: format.DeterminismByte,
Properties: map[string]any{
- "encoding": "utf-8",
- "line_ending": "lf",
+ "encoding": "utf-8",
+ // none for a page on one line: it has no line to end, and lf
+ // would describe a file that is not there.
+ "line_ending": s.lineEnding(),
"doctype": doctype,
"language": "en",
},
@@ -154,9 +155,10 @@ func belowMinimum(s settings, requested, min int64) error {
// the reason names, whatever else was asked.
reason = core.Says("html.MinimumWithPictures", "the file holds whole blocks and every picture in base64, which is about a third larger than the picture itself, and together they need that much")
case s.adds():
- reason = core.Says("html.MinimumWithSettings", "the file holds whole blocks and what %s, %s, %s and %s add to them, and together they need that much",
+ reason = core.Says("html.MinimumWithSettings", "the file holds whole blocks and what %s, %s, %s, %s and %s add to them, and together they need that much",
core.A("Nesting", core.LabelTerm(settingNesting)), core.A("Characters", core.LabelTerm(settingCharacters)),
- core.A("Elements", core.LabelTerm(settingElements)), core.A("BOM", core.LabelTerm(textenc.SettingBOM)))
+ core.A("Elements", core.LabelTerm(settingElements)), core.A("BOM", core.LabelTerm(textenc.SettingBOM)),
+ core.A("Styles", core.LabelTerm(settingStyles)))
case s.shape == structureFragment:
reason = core.Says("html.MinimumFragment", "a fragment holds whole blocks, and one of them needs that much")
}
@@ -178,7 +180,8 @@ func belowMinimum(s settings, requested, min int64) error {
// fit and what does it cost", and Plan answers "is this request askable at all".
func labelledHead(s settings, r format.Request, min int64) (head, heading string, note *format.Note) {
label := core.Label("html", r.Bytes, r.Seed)
- heading = "" + label + "
\n"
+ m := s.spelling()
+ heading = m[labelOpen] + label + m[labelClose]
plain := s.prologue()
head, extra := plain, int64(0)
@@ -229,5 +232,5 @@ func minimumBytes() int64 { return minimumFor(settings{shape: structureDocument}
// whole block, computed from the pieces Write uses rather than written down so
// it cannot drift away from the template.
func minimumFor(s settings) int64 {
- return int64(len(s.prologue())) + s.openingBytes() + int64(len(paraOpen)) + s.tailBytes()
+ return int64(len(s.prologue())) + s.openingBytes() + int64(len(s.spelling()[paraOpen])) + s.tailBytes()
}
diff --git a/internal/format/htmlfile/markup.go b/internal/format/htmlfile/markup.go
new file mode 100644
index 00000000..d3baf7bc
--- /dev/null
+++ b/internal/format/htmlfile/markup.go
@@ -0,0 +1,265 @@
+package htmlfile
+
+import (
+ "regexp"
+ "strings"
+)
+
+// How a page is spelled: every piece of markup it is written with, for one
+// layout and one way of carrying its styles (docs/HTML-4-2026-10-14.md).
+//
+// Held as finished strings, the way the JSON layouts hold theirs, because the
+// arithmetic that lands a page on its byte count has to measure what it writes
+// rather than predict it. The plain spelling is the bytes every page has been
+// written with, and every other spelling is made from it by two rules - the
+// rules of the sheet put on each element, and the line breaks taken out. The
+// pieces sit in one array, so a rule reaches every piece there is, including
+// one added later, and there is no list of them to forget one on.
+
+// markup is one spelling of a page, a piece to each part.
+type markup [parts]string
+
+// part names one piece of a page's markup.
+type part int
+
+// The pieces a page writes itself, then those it does not. Everything before
+// ownParts is spelled by the rules below, and the catalogue of every element,
+// written by hand once, keeps its look and has a rule of its own.
+const (
+ // The skeleton in two halves, the style sheet between them or in front of
+ // a fragment, and what closes the body and the document.
+ headOpen part = iota
+ headClose
+ sheet
+ bodyClose
+ // The label, one level of nesting, and a paragraph.
+ labelOpen
+ labelClose
+ divOpen
+ divClose
+ paraOpen
+ paraClose
+ // The blocks that fill a page.
+ h2Open
+ h2Close
+ ulOpen
+ liOpen
+ liClose
+ ulClose
+ tableOpen
+ rowOpen
+ cellSep
+ rowClose
+ tableClose
+ quoteOpen
+ quoteClose
+ // The paragraphs in other scripts, and a picture's line around its name
+ // and its base64.
+ scripts
+ pictureLead
+ pictureTail
+
+ ownParts
+)
+
+// The catalogue's two pieces, after the page's own.
+const (
+ headCatalogue = ownParts + iota
+ bodyCatalogue
+ parts
+)
+
+// 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.
+var plain = markup{
+ headOpen: `` + "\n" +
+ `` + "\n" +
+ `` + "\n" +
+ `` + "\n" +
+ emptyTitle + "\n",
+ headClose: `` + "\n" +
+ `` + "\n",
+ bodyClose: "\n\n",
+ labelOpen: "",
+ labelClose: "
\n",
+ // One level of nesting, each on a line of its own.
+ divOpen: "\n",
+ divClose: "
\n",
+ paraOpen: "",
+ paraClose: "
\n",
+ h2Open: "",
+ h2Close: "
\n",
+ ulOpen: "\n",
+ liOpen: "- ",
+ liClose: "
\n",
+ ulClose: "
\n",
+ tableOpen: "\n| id | field | value |
\n",
+ rowOpen: "| ",
+ cellSep: " | ",
+ rowClose: " |
\n",
+ // The table closes and the quote holds a paragraph.
+ tableClose: "
\n",
+ quoteOpen: "",
+ quoteClose: "
\n",
+ scripts: international,
+ pictureLead: `![`,
+ pictureTail: `]()
` + "\n",
+ headCatalogue: catalogueHead,
+ bodyCatalogue: catalogueBody,
+}
+
+// rule is the look of one element: its declarations, property and value.
+type rule struct {
+ element string
+ declarations [][2]string
+}
+
+// rules are the styles a page carries, one list for both places they go. A
+// font every system has, colours and spacing, and nothing that names an
+// address: a page that fetches a font or a picture for its style reaches a
+// network when it is opened (D16). No rule for div either, or the levels of
+// nesting would each push the content further in.
+var rules = []rule{
+ {"h1", [][2]string{{"font-family", "sans-serif"}, {"font-size", "28px"}, {"color", "#0b3d91"}, {"border-bottom", "2px solid #0b3d91"}, {"margin", "0 0 16px"}}},
+ {"h2", [][2]string{{"font-family", "sans-serif"}, {"font-size", "20px"}, {"color", "#0b3d91"}, {"margin", "16px 0 8px"}}},
+ {"p", [][2]string{{"font-family", "sans-serif"}, {"color", "#222222"}, {"line-height", "1.5"}, {"margin", "0 0 12px"}}},
+ {"ul", [][2]string{{"margin", "0 0 12px"}, {"padding-left", "24px"}}},
+ {"li", [][2]string{{"font-family", "sans-serif"}, {"color", "#222222"}, {"margin", "4px 0"}}},
+ {"table", [][2]string{{"border-collapse", "collapse"}, {"margin", "0 0 12px"}}},
+ {"th", [][2]string{{"font-family", "sans-serif"}, {"background", "#0b3d91"}, {"color", "#ffffff"}, {"text-align", "left"}, {"padding", "4px 8px"}}},
+ {"td", [][2]string{{"font-family", "sans-serif"}, {"border", "1px solid #cccccc"}, {"padding", "4px 8px"}}},
+ {"blockquote", [][2]string{{"border-left", "4px solid #cccccc"}, {"margin", "12px 0"}, {"padding-left", "12px"}, {"color", "#555555"}}},
+}
+
+// css is a rule's declarations, readable on a page of lines and as short as
+// they go on a page of one.
+func (r rule) css(minified bool) string {
+ colon, sep := ": ", "; "
+ if minified {
+ colon, sep = ":", ";"
+ }
+ parts := make([]string, len(r.declarations))
+ for i, d := range r.declarations {
+ parts[i] = d[0] + colon + d[1]
+ }
+ return strings.Join(parts, sep)
+}
+
+// sheetOf is the rules as a style element: one rule to a line, or all of them
+// on one.
+func sheetOf(minified bool) string {
+ var b strings.Builder
+ if minified {
+ b.WriteString("")
+ return b.String()
+ }
+ b.WriteString("\n")
+ return b.String()
+}
+
+// styledTag finds the start tag of every element a rule is for, in one pass, so
+// a tag given an attribute is never found again. The space or the bracket after
+// the name keeps p from matching pre and th from matching thead.
+var styledTag = func() *regexp.Regexp {
+ names := make([]string, len(rules))
+ for i, r := range rules {
+ names[i] = r.element
+ }
+ return regexp.MustCompile(`<(` + strings.Join(names, "|") + `)([ >])`)
+}()
+
+// withStyles puts each element's rule on it as a style attribute, first among
+// its attributes.
+func withStyles(s string, minified bool) string {
+ css := make(map[string]string, len(rules))
+ for _, r := range rules {
+ css[r.element] = r.css(minified)
+ }
+ return styledTag.ReplaceAllStringFunc(s, func(tag string) string {
+ m := styledTag.FindStringSubmatch(tag)
+ return "<" + m[1] + ` style="` + css[m[1]] + `"` + m[2]
+ })
+}
+
+// oneLineCatalogue is the catalogue on one line. Where a line ended it keeps a
+// space, because between two inline elements a line break shows as one - taken
+// out, 52 of its 575 elements move on the screen (Chromium 152, 2026-10-14).
+// The one break that is text, in the pre element, becomes a reference to the
+// character, which is what keeps its text the same.
+func oneLineCatalogue(body string) string {
+ body = strings.Replace(body, preformatted, strings.ReplaceAll(preformatted, "\n", "
"), 1)
+ return strings.ReplaceAll(body, "\n", " ")
+}
+
+// spell makes the spelling of one layout and one way of carrying styles out of
+// the plain one.
+func spell(minified bool, styles string) *markup {
+ m := plain
+ if styles == stylesInline {
+ for p := range ownParts {
+ m[p] = withStyles(m[p], minified)
+ }
+ }
+ if styles == stylesEmbedded {
+ m[sheet] = sheetOf(minified)
+ }
+ if minified {
+ for p := range ownParts {
+ m[p] = strings.ReplaceAll(m[p], "\n", "")
+ }
+ m[headCatalogue] = strings.ReplaceAll(m[headCatalogue], "\n", "")
+ m[bodyCatalogue] = oneLineCatalogue(m[bodyCatalogue])
+ }
+ return &m
+}
+
+// spellings are made once, when the program starts: the engine holds the plan
+// of every file at once, and a plan holds a pointer to one of these rather
+// than a copy of the attributes on every element.
+var spellings = func() map[spellingKey]*markup {
+ out := map[spellingKey]*markup{}
+ for _, minified := range []bool{false, true} {
+ for _, styles := range []string{stylesNone, stylesEmbedded, stylesInline} {
+ out[spellingKey{minified, styles}] = spell(minified, styles)
+ }
+ }
+ return out
+}()
+
+type spellingKey struct {
+ minified bool
+ styles string
+}
+
+// styleName is the value of styles, none for settings made without one.
+func (s settings) styleName() string {
+ if s.styles == "" {
+ return stylesNone
+ }
+ return s.styles
+}
+
+// spelling is how these settings spell a page.
+func (s settings) spelling() *markup {
+ return spellings[spellingKey{s.minified, s.styleName()}]
+}
+
+// lineEnding is what the manifest says the lines of the page end with: none for
+// a page on one line, which has no line to end, where lf would describe a file
+// that is not there.
+func (s settings) lineEnding() string {
+ if s.minified {
+ return "none"
+ }
+ return "lf"
+}
diff --git a/internal/format/htmlfile/page.go b/internal/format/htmlfile/page.go
index 409623aa..b0ebf906 100644
--- a/internal/format/htmlfile/page.go
+++ b/internal/format/htmlfile/page.go
@@ -8,32 +8,11 @@ import "strings"
// nesting, the paragraphs in other scripts, the catalogue, the blocks, and the
// closing paragraph with everything it closes after it.
-const (
- // The skeleton of a page, in two halves so the catalogue can put what it
- // needs into the head between them. Joined they are the bytes every page
- // opened with before there were settings.
- headOpen = `` + "\n" +
- `` + "\n" +
- `` + "\n" +
- `` + "\n" +
- `` + "\n"
- headClose = `` + "\n" +
- `` + "\n"
+// 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.
- // The title is filled in per file when a label is wanted, so the empty pair
- // above is what the smallest document carries.
- emptyTitle = ``
-
- paraOpen = ""
- paraClose = "
\n"
- bodyClose = "\n\n"
-
- // One level of nesting, each on a line of its own. A page whose levels
- // shared a line would be a page of one very long line, which is a different
- // question with a setting of its own to come.
- divOpen = "\n"
- divClose = "
\n"
-)
+// emptyTitle is the title a page carries until a label fills it in.
+const emptyTitle = ``
// 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
@@ -41,12 +20,17 @@ const (
var byteOrderMark = string([]byte{0xEF, 0xBB, 0xBF})
// prologue is everything in front of the label: the mark, and the skeleton down
-// to the body with whatever the catalogue puts in the head - or for a fragment
-// nothing but the mark.
+// to the body with whatever the catalogue and the style sheet put in the head -
+// or for a fragment the mark and the sheet. A fragment has no head, and a sheet
+// at its very start is one a parser puts into the head it makes up, where the
+// Nu HTML Checker finds nothing wrong with it. Further down it would be a
+// sheet in the body, which the checker calls an error rule by rule
+// (docs/HTML-4-2026-10-14.md section 1).
func (s settings) prologue() string {
- p := ""
+ m := s.spelling()
+ p := m[sheet]
if s.shape == structureDocument {
- p = headOpen + s.catalogueHeadFor() + headClose
+ p = m[headOpen] + s.catalogueHeadFor() + m[sheet] + m[headClose]
}
if s.bom {
p = byteOrderMark + p
@@ -59,12 +43,12 @@ func (s settings) prologue() string {
// kept in the plan - a hundred thousand levels are 600 KB, and the engine holds
// the plan of every file at once.
func (s settings) opening() string {
- return strings.Repeat(divOpen, s.nesting) + s.internationalBlocks() + s.catalogueBlocks()
+ return strings.Repeat(s.spelling()[divOpen], s.nesting) + s.internationalBlocks() + s.catalogueBlocks()
}
// openingBytes is the length of opening, worked out without building it.
func (s settings) openingBytes() int64 {
- return int64(s.nesting*len(divOpen) + len(s.internationalBlocks()) + len(s.catalogueBlocks()))
+ return int64(s.nesting*len(s.spelling()[divOpen]) + len(s.internationalBlocks()) + len(s.catalogueBlocks()))
}
// blocks is the body builder for these settings.
@@ -76,23 +60,28 @@ func (s settings) openingBytes() int64 {
// nested a thousand deep with not one - the right size, deterministic,
// and nonsense.
func (s settings) blocks() blocks {
- tail := paraClose + strings.Repeat(divClose, s.nesting)
+ m := s.spelling()
+ tail := m[paraClose] + strings.Repeat(m[divClose], s.nesting)
if s.shape == structureDocument {
- tail += bodyClose
+ tail += m[bodyClose]
}
- return blocks{tail: tail}
+ return blocks{tail: tail, m: m}
}
// tailBytes is the length of what the last record ends with, worked out
// without building it.
func (s settings) tailBytes() int64 {
- n := len(paraClose) + s.nesting*len(divClose)
+ m := s.spelling()
+ n := len(m[paraClose]) + s.nesting*len(m[divClose])
if s.shape == structureDocument {
- n += len(bodyClose)
+ n += len(m[bodyClose])
}
return int64(n)
}
// adds says the settings put something into the page beyond the skeleton and
-// the blocks, which is what the refusal of a size too small has to name.
-func (s settings) adds() bool { return s.nesting > 0 || s.international || s.every || s.bom }
+// the blocks, which is what the refusal of a size too small has to name. A page
+// on one line is smaller than the same page on many, so formatting adds nothing.
+func (s settings) adds() bool {
+ return s.nesting > 0 || s.international || s.every || s.bom || s.styleName() != stylesNone
+}
diff --git a/internal/format/htmlfile/pictures.go b/internal/format/htmlfile/pictures.go
index 80d67482..5e23ebc6 100644
--- a/internal/format/htmlfile/pictures.go
+++ b/internal/format/htmlfile/pictures.go
@@ -13,8 +13,9 @@ import (
)
// Pictures inside the page: real files of a picture format, each in a data:
-// URL (RFC 2397) on a line of its own, the way a page saved as one file
-// carries them. The settings are the ones a mail message has, with the same
+// URL (RFC 2397) in a paragraph of its own - on a line of its own too, unless
+// the whole page is one line - the way a page saved as one file carries them.
+// The settings are the ones a mail message has, with the same
// names and the same meaning (package picture).
//
// Measured on 2026-10-14 on pages from this program (docs/HTML-3-2026-10-14.md
@@ -32,14 +33,11 @@ const picturesGroup = "Pictures in the page"
// reason.
const maxPage = int64(1) << 60
-// pictureClose ends the line a picture sits on.
-const pictureClose = `">` + "\n"
-
// pictureProperties are the settings of the pictures the page shows.
func pictureProperties() []format.Property {
return picture.Properties(picturesGroup, picture.Details{
Count: "How many pictures the page shows, each one a real file of its format written into the page as a data: URL, so opening the page fetches nothing. " +
- "They sit above the blocks, one to a line, and each says which it is in its alt text.",
+ "They sit above the blocks, each in a paragraph of its own, and each says which it is in its alt text.",
Format: "The format of the pictures. Any format whose files are pictures, such as png, jpg or gif. Chromium shows every one of them but tiff.",
Size: "How big each picture is. Inside the page it is base64 on one line, about a third larger. " +
"lxml, and BeautifulSoup on top of it, leave out a picture past about 7.1 MB unless told to read huge trees. " +
@@ -48,23 +46,23 @@ func pictureProperties() []format.Property {
}
// pictureLine is one picture, planned: the file, and the line it sits on up
-// to where the base64 starts.
+// to where the base64 starts and after it ends.
type pictureLine struct {
- desc format.Descriptor
- plan format.Plan
- open string
+ desc format.Descriptor
+ plan format.Plan
+ open, close string
}
// bytes is what the line takes in the page.
func (p pictureLine) bytes() int64 {
- return int64(len(p.open)) + base64Length(p.plan.Bytes) + int64(len(pictureClose))
+ return int64(len(p.open)) + base64Length(p.plan.Bytes) + int64(len(p.close))
}
// planPictures plans the pictures, each with the seed of its place (RC4), so
// asking for one more changes none of the others. The format is asked whether
// it is a picture even when none are asked for: a value the setting cannot
// take is a mistake either way, and eml refuses it the same way.
-func planPictures(ctx context.Context, r format.Request, s picture.Settings) ([]pictureLine, int64, error) {
+func planPictures(ctx context.Context, r format.Request, s picture.Settings, m *markup) ([]pictureLine, int64, error) {
desc, ok, err := picture.Of(s.Format)
if err != nil {
return nil, 0, err
@@ -87,7 +85,7 @@ func planPictures(ctx context.Context, r format.Request, s picture.Settings) ([]
if cp.Bytes > maxPage {
return nil, 0, pageTooLarge(cp.Bytes)
}
- line := pictureLine{desc: desc, plan: cp, open: pictureOpen(desc, i+1)}
+ line := pictureLine{desc: desc, plan: cp, open: pictureOpen(m, desc, i+1), close: m[pictureTail]}
if sum += line.bytes(); sum > maxPage {
return nil, 0, pageTooLarge(sum)
}
@@ -99,9 +97,9 @@ func planPictures(ctx context.Context, r format.Request, s picture.Settings) ([]
// pictureOpen is a picture's line up to its base64. The alt text is the name
// the picture has in a mail message, inline_0001.png, so a tester reading
// either finds the same picture by the same name.
-func pictureOpen(desc format.Descriptor, n int) string {
+func pictureOpen(m *markup, desc format.Descriptor, n int) string {
name := fmt.Sprintf("inline_%04d%s", n, desc.Extension)
- return `
\n", n)
+ end := strings.Repeat(""+nl, n)
if props["structure"] != "fragment" {
- end += "