From 202d504eb378de744a3cb6d2af4523eaf76a7765 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 14 Oct 2026 10:34:13 +0200 Subject: [PATCH 1/2] html: deep pages, text in other scripts, every element of the standard, and a byte order mark Four new settings for the html format, each off by default, so files made with the earlier settings keep their bytes. - nesting puts the content inside up to 100 000 div elements, the label outside them. lxml drops the content of a page about 250 deep, Chromium flattens past 511, and jsdom, parse5 and DOMPurify can run out of stack writing out a page from about 2 000 deep. - characters=international adds paragraphs in Polish, Russian, Japanese and Arabic (right to left), emoji, a letter in two code points and entities. - elements=every adds a block using every element of the HTML standard but script, all 22 kinds of input, and pictures and a sound written into the page as data. The Nu HTML Checker finds nothing wrong with it. - bom puts a UTF-8 byte order mark in front, from the shared text encoding axis. Content sniffing as the WHATWG describes it then reads the page as plain text. The structural check is told every setting and checks each one from the bytes. Four golden cases pin the new paths. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 18 + README.md | 2 +- internal/format/htmlfile/blocks.go | 117 +++++++ internal/format/htmlfile/catalogue.go | 154 +++++++++ internal/format/htmlfile/characters.go | 38 +++ internal/format/htmlfile/html.go | 308 +++++------------- internal/format/htmlfile/page.go | 98 ++++++ internal/format/htmlfile/settings.go | 174 ++++++++++ internal/guard/generatorbytes_test.go | 23 +- internal/guard/htmlcatalogue_test.go | 144 ++++++++ internal/guard/htmlsettings_test.go | 209 ++++++++++++ internal/guard/layers_test.go | 2 +- internal/guard/testdata/generator-golden.json | 16 + internal/gui/text/locale/registry/en.json | 55 ++++ internal/gui/text/locale/registry/pl.json | 11 + internal/gui/text/locale/said/en.json | 25 ++ internal/gui/text/locale/said/pl.json | 5 + internal/oracle/strict.py | 129 +++++++- web/public/ar/formats/index.html | 20 ++ web/public/cs/formaty/index.html | 20 ++ web/public/de/formate/index.html | 20 ++ web/public/es/formatos/index.html | 20 ++ web/public/formats/index.html | 20 ++ web/public/fr/formats/index.html | 20 ++ web/public/hi/formats/index.html | 20 ++ web/public/id/format/index.html | 20 ++ web/public/it/formati/index.html | 20 ++ web/public/ja/formats/index.html | 20 ++ web/public/ko/formats/index.html | 20 ++ web/public/nl/formaten/index.html | 20 ++ web/public/pl/formaty/index.html | 20 ++ web/public/pt-br/formatos/index.html | 20 ++ web/public/ro/formate/index.html | 20 ++ web/public/ru/formats/index.html | 20 ++ web/public/th/formats/index.html | 20 ++ web/public/tr/bicimler/index.html | 20 ++ web/public/uk/formats/index.html | 20 ++ web/public/vi/dinh-dang/index.html | 20 ++ web/public/zh-hans/formats/index.html | 20 ++ web/public/zh-hant/formats/index.html | 20 ++ 40 files changed, 1725 insertions(+), 243 deletions(-) create mode 100644 internal/format/htmlfile/blocks.go create mode 100644 internal/format/htmlfile/catalogue.go create mode 100644 internal/format/htmlfile/characters.go create mode 100644 internal/format/htmlfile/page.go create mode 100644 internal/format/htmlfile/settings.go create mode 100644 internal/guard/htmlcatalogue_test.go create mode 100644 internal/guard/htmlsettings_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fd2fd6a..c39cc220 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,6 +139,24 @@ because it turns other people's test suites red. antimeridian has its west edge greater than its east edge. GDAL opens a file whose bbox is wrong without a word, so a reader that relies on bbox needs a test of its own. Files made with the earlier settings keep their bytes. +- **HTML: deep pages, text in other scripts, every element, and a byte order + mark.** `nesting` puts the content of a page inside up to 100 000 div + elements, each inside the last, with the label outside them. Readers part + ways early: lxml drops everything but the label from a page about 250 + deep, Chromium keeps 511 levels and moves the deeper ones out, and jsdom, + parse5 and DOMPurify can run out of stack writing out a page from about + 2 000 deep. `characters=international` adds paragraphs in Polish, Russian, + Japanese and Arabic, the Arabic right to left, with emoji, a letter written + as two code points and entities. `elements=every` adds a block that uses + every element of the HTML standard but script at least once, with all 22 + kinds of input and small pictures and a sound written into the page, so + opening it fetches nothing - the Nu HTML Checker finds nothing wrong with + it, and pandoc's reader leaves out the header, the aside and the footer. + `bom` puts a byte order mark in front. A page with one is still HTML to + Chromium and to `file`, and plain text to the content sniffing the WHATWG + describes, which Go's `net/http` follows - so a filter that recognises + HTML by its first bytes can let it through. 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 c1a349be..1632f949 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` | +| `html` | `structure`, `nesting`, `characters`, `elements`, `bom` | | `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 new file mode 100644 index 00000000..3b797a7f --- /dev/null +++ b/internal/format/htmlfile/blocks.go @@ -0,0 +1,117 @@ +package htmlfile + +import ( + // D11 promises the same bytes from the same seed, so a deliberate, + // reproducible generator is the product rather than a weakness. Nothing + // here ever makes a secret. + // nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used + "math/rand/v2" + "strconv" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + +// The blocks that fill a page, split out of html.go on 2026-10-14 with the +// settings. Nothing here changed in that move - these are the bytes every page +// has been made of since 2026-08-01, and the golden hashes hold them there. + +// blocks builds the body. A natural record is one complete block element and +// the closing record is a paragraph stretched to land the byte count. +// +// Whole blocks only, for the same reason Markdown writes whole blocks: a table +// or a list cut in the middle still renders, and it says something other than +// it meant. +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 +} + +// 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 (blocks) Append(dst []byte, rng *rand.Rand) []byte { + switch rng.IntN(5) { + case 0: + dst = append(dst, "

"...) + dst = appendPhrase(dst, rng, 2+rng.IntN(3)) + return append(dst, "

\n"...) + case 1: + dst = append(dst, "
    \n"...) + for i, n := 0, 2+rng.IntN(3); i < n; i++ { + dst = append(dst, "
  • "...) + dst = appendPhrase(dst, rng, 3+rng.IntN(4)) + dst = append(dst, "
  • \n"...) + } + return append(dst, "
\n"...) + case 2: + dst = append(dst, "\n\n"...) + for i, n := 0, 2+rng.IntN(3); i < n; i++ { + dst = append(dst, "\n"...) + } + return append(dst, "
idfieldvalue
"...) + dst = strconv.AppendInt(dst, int64(i+1), 10) + dst = append(dst, ""...) + dst = append(dst, words[rng.IntN(len(words))]...) + dst = append(dst, ""...) + dst = append(dst, words[rng.IntN(len(words))]...) + dst = append(dst, "
\n"...) + 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 = appendPhrase(dst, rng, 4+rng.IntN(4)) + dst = append(dst, " & "...) + dst = appendPhrase(dst, rng, 2) + return append(dst, "

\n"...) + default: + dst = append(dst, paraOpen...) + dst = appendPhrase(dst, rng, 12+rng.IntN(12)) + return append(dst, paraClose...) + } +} + +// Discard has nothing to put back. A block carries no state from one to the +// next, so throwing one away leaves no trace to undo. +func (blocks) Discard() {} + +func (b blocks) AppendExact(dst []byte, rng *rand.Rand, n int64) []byte { + start := len(dst) + dst = append(dst, paraOpen...) + used := int64(len(dst)-start) + int64(len(b.tail)) + dst = appendFiller(dst, n-used) + return append(dst, b.tail...) +} + +func appendPhrase(dst []byte, rng *rand.Rand, n int) []byte { + for i := 0; i < n; i++ { + if i > 0 { + dst = append(dst, ' ') + } + dst = append(dst, words[rng.IntN(len(words))]...) + } + return dst +} + +// appendFiller writes exactly n bytes of paragraph text out of readable words. +// +// It never emits an ampersand or an angle bracket, the two characters that +// would have to be escaped - an escape would make the text longer than the +// count asked for. +// appendFiller stretches the closing paragraph to the byte. +func appendFiller(dst []byte, n int64) []byte { + return core.AppendFiller(dst, words, n, nil) +} + +// words is the filler vocabulary. English by default, like the rest of the text +// group. +var words = []string{ + "account", "anchor", "banner", "browser", "caption", "content", "control", + "default", "element", "feature", "footer", "gallery", "handler", "header", + "heading", "inline", "layout", "listing", "margin", "marker", "message", + "module", "navigation", "notice", "option", "padding", "preview", "profile", + "section", "selector", "sidebar", "summary", "template", "toolbar", + "tooltip", "viewport", "widget", "wrapper", +} diff --git a/internal/format/htmlfile/catalogue.go b/internal/format/htmlfile/catalogue.go new file mode 100644 index 00000000..d08d7d7e --- /dev/null +++ b/internal/format/htmlfile/catalogue.go @@ -0,0 +1,154 @@ +package htmlfile + +// The catalogue a page carries when elements is every: every element the HTML +// standard lists, each one at least once, in a page the Nu HTML Checker calls +// conforming. +// +// The list was read from the standard itself on 2026-10-14 - the index "List of +// elements", 115 names - rather than from memory, which would have missed +// search, selectedcontent and the return of menu. The draft of this block went +// through four readers before it came here (docs/HTML-2-2026-10-14.md section +// 11.2): the checker found one error in it, a label around the new select whose +// button is a second thing to label, and passes it now with nothing to say. +// Chromium 152 builds every element, none of them unknown. +// +// One element is left out, and on purpose: script. A page that runs code is a +// security fixture, and those are a separate, opt-in set (decision D3). Nothing +// here reaches a network either - every picture, the sound and the frame are +// written into the page as data, so opening the file fetches nothing. +// +// The text is plain English in ASCII, the ruby included, so this setting and +// characters stay independent of each other: a page can carry every element +// with no letter outside ASCII, and every script with nothing but paragraphs. + +const ( + // picture is an 8 by 8 PNG of one colour, 74 bytes, and sound 16 samples of + // silence as an 8 bit WAV at 8 kHz, 60 bytes. Both were made once and are + // written here as they are, so the page is the same on every run. + picture = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAAEUlEQVR42mNwaDiAFTEMLQkAYvNgAQlPC90AAAAASUVORK5CYII=" + sound = "data:audio/wav;base64,UklGRjQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YRAAAACAgICAgICAgICAgICAgICA" + + // 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. + catalogueHead = `` + "\n" + + `` + "\n" + + `` + "\n" + + "\n" + + 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.

+

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

+

Third level

+

Fourth level

+
Fifth level
+
Sixth level
+

A quotation in a block.

+
preformatted   text
+  keeps   its spaces
+
+
Term
Its description.
+
  1. three
  2. two
+
  • +
    Example Street 1, Example City
    +
    Summary of the details

    The details themselves.

    +

    A dialog that is open.

    +

    A section

    Inside a section.

    +
    A div.
    +
    +
    +

    Tables

    + + + + + + +
    A table with every part
    idfieldvalue
    1firstalpha
    spans two columns
    footer row
    +
    +
    +

    Forms

    +
    +
    +Every type of input + + + + + + + + + + + + + + + + + + + + + + +
    + + + + +output + + + +
    +
    +
    +

    Media

    +
    a picture
    A picture with a map.
    +the whole picture +a picture with a source + + +a canvas + +an object + + +
    +
    + + + +

    Page footer.

    +` +) + +// pageOnly is the elements that live only in a page's skeleton or its head, +// which a fragment has neither of. +var pageOnly = []string{"base", "body", "head", "html", "link", "meta", "style", "title"} + +// catalogueHeadFor is what the catalogue adds to the head, nothing when there +// is no catalogue or no head. +func (s settings) catalogueHeadFor() string { + if !s.every || s.shape != structureDocument { + return "" + } + return catalogueHead +} + +// catalogueBlocks is what the catalogue adds to the body. +func (s settings) catalogueBlocks() string { + if !s.every { + return "" + } + return catalogueBody +} diff --git a/internal/format/htmlfile/characters.go b/internal/format/htmlfile/characters.go new file mode 100644 index 00000000..5520a8a4 --- /dev/null +++ b/internal/format/htmlfile/characters.go @@ -0,0 +1,38 @@ +package htmlfile + +// The paragraphs a page carries when characters is international, measured on +// four readers on 2026-10-14 (docs/HTML-2-2026-10-14.md section 11.4). +// +// Polish, Russian and Japanese are written as they are, so the line can be +// read here. The Arabic is escaped, because text written right to left turns +// the line around in an editor, and so are the characters nobody can see: the +// joiner inside the emoji sequence and the accent that makes a second code +// point of the e. Written as they are, a change to either would look like no +// change at all. +// +// Each script is a paragraph of its own with its own lang, the Arabic one with +// dir="rtl", because that is how a page says which language a passage is in - +// and the attribute is what a reader keys its fonts and its direction on. +const ( + arabic = "\u0647\u0630\u0627 \u0646\u0635 \u0628\u0627\u0644\u0644\u063A\u0629 \u0627\u0644\u0639\u0631\u0628\u064A\u0629." + + international = `

    Zażółć gęślą jaźń.

    ` + "\n" + + `

    Это текст на русском языке.

    ` + "\n" + + `

    これは日本語のテキストです。

    ` + "\n" + + `

    ` + arabic + `

    ` + "\n" + + // The entities are the ones a converter most often gets wrong: the + // five that have to be escaped, a non-breaking space, two named + // symbols, and a number written in decimal and in hex, one of them past + // the basic plane. + `

    Emoji ` + "\U0001F600" + ` and a sequence ` + "\U0001F469\u200D\U0001F4BB" + `, ` + + `a letter in two code points e` + "\u0301" + `, ` + + `and entities & < > "   © € € 😀.

    ` + "\n" +) + +// internationalBlocks is what the setting adds, nothing for ascii. +func (s settings) internationalBlocks() string { + if !s.international { + return "" + } + return international +} diff --git a/internal/format/htmlfile/html.go b/internal/format/htmlfile/html.go index 33bda56f..0928f860 100644 --- a/internal/format/htmlfile/html.go +++ b/internal/format/htmlfile/html.go @@ -9,16 +9,11 @@ import ( "context" "fmt" "io" - // D11 promises the same bytes from the same seed, so a deliberate, - // reproducible generator is the product rather than a weakness. Nothing - // here ever makes a secret. - // nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used - "math/rand/v2" - "strconv" "strings" "github.com/donislawdev/TestingFilesGenerator/internal/core" "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/format/textenc" ) // Measured on 2026-08-01, a comment holds arbitrary bytes to 1 MiB both after @@ -35,86 +30,7 @@ import ( // The guards written against this format carry more of the weight than they do // for SVG, which has a renderer behind it. -const ( - generatorVersion = "1" - - prologue = `` + "\n" + - `` + "\n" + - `` + "\n" + - `` + "\n" + - `` + "\n" + - `` + "\n" + - `` + "\n" - - // 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" - - tailLast = paraClose + bodyClose - - // tailFragment is what a fragment's closing record ends with. A fragment - // closes its last paragraph and stops, because it has no body and no - // document to close. - tailFragment = paraClose -) - -// The shape of the file: a whole page, or only the blocks that would sit in one. -const ( - settingStructure = "structure" - structureDocument = "document" - structureFragment = "fragment" -) - -// structureOf reads the setting. -// -// A value outside the declared set has already been refused by the registry, -// which checks every format against its declaration in one place. This branch -// stays for the same reason the CSV dialect and the text encoding keep theirs: -// this function is callable directly, a guard is such a caller, and a generator -// that trusts its input is one registry change away from writing a file nobody -// ordered. -func structureOf(props map[string]string) (string, error) { - v, ok := props[settingStructure] - if !ok || v == "" { - return structureDocument, nil - } - switch v { - case structureDocument, structureFragment: - return v, nil - } - return "", &format.PropertyValueError{ - Format: "html", Key: settingStructure, Value: v, - Reason: core.Says("html.HasToBeStructure", "it has to be %s or %s", - core.A("Document", core.Choice{Of: settingStructure, Value: structureDocument}), - core.A("Fragment", core.Choice{Of: settingStructure, Value: structureFragment})), - } -} - -// prologueFor is the skeleton down to the body, empty for a fragment. -func prologueFor(shape string) string { - if shape == structureFragment { - return "" - } - return prologue -} - -// blocksFor is the body builder for one shape. -// -// The shape has to reach the BUILDER and not only the prologue, and that is the -// part of this easy to miss: the bytes that close the body and the document sit -// in the last RECORD rather than in a footer. A change that swapped only the -// prologue would end a fragment with - the right size, -// deterministic, and nonsense. -func blocksFor(shape string) blocks { - if shape == structureFragment { - return blocks{tail: tailFragment} - } - return blocks{tail: tailLast} -} +const generatorVersion = "1" func init() { format.Register(format.Descriptor{ @@ -140,16 +56,11 @@ func init() { // than a comment. Label: format.LabelVisible, Oracle: "python-html", - // Element counts, inline CSS and JS, images, forms and the "every HTML5 - // tag" variant come later. Declaring only what is here is what makes a - // recipe asking for them fail loudly instead of quietly producing - // something else. - Properties: []format.Property{{ - Name: settingStructure, Kind: format.PropertyChoice, - Choices: []string{structureDocument, structureFragment}, - Default: structureDocument, - Detail: "Whether the file is a whole page or only the blocks that would sit inside one. A fragment has no doctype, no html element and no body, which is what a content field or the body of an email really holds. It is far smaller, so the smallest fragment sits well below the smallest page.", - }}, + // Pictures inside the page, 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. + Properties: properties(), GeneratorVersion: generatorVersion, Generator: generator{}, }) @@ -158,37 +69,27 @@ func init() { type generator struct{} type memo struct { - head string // the whole skeleton down to , title included + head string // the prologue, title included labelLine string // the visible heading, empty when absent seed uint64 - shape string + settings settings } func (generator) Plan(r format.Request) (format.Plan, error) { - shape, err := structureOf(r.Properties) + s, err := settingsOf(r.Properties) if err != nil { return format.Plan{}, err } - min := minimumFor(shape) + min := minimumFor(s) if r.Bytes < min { - reason := core.Says("html.MinimumPage", "a page holds a head, a body and whole blocks, and one of each needs that much") - if shape == structureFragment { - reason = core.Says("html.MinimumFragment", "a fragment holds whole blocks, and one of them needs that much") - } - return format.Plan{}, &format.BelowMinimumError{ - Format: "HTML", - Requested: r.Bytes, - Minimum: min, - Reason: reason, - Hint: core.Says("format.AskForBOrMore", "Ask for %d B or more.", core.A("Min", min)), - } + return format.Plan{}, belowMinimum(s, r.Bytes, min) } // A fragment has no doctype, and saying it has one would be the manifest // describing a file that is not there. doctype := "html" - if shape == structureFragment { + if s.shape == structureFragment { doctype = "none" } @@ -197,18 +98,30 @@ func (generator) Plan(r format.Request) (format.Plan, error) { Exact: true, Determinism: format.DeterminismByte, Properties: map[string]any{ - "encoding": "utf-8", - "line_ending": "lf", - "doctype": doctype, - "language": "en", - settingStructure: shape, + "encoding": "utf-8", + "line_ending": "lf", + "doctype": doctype, + "language": "en", }, } + for k, v := range s.manifest() { + p.Properties[k] = v + } + if s.every && s.shape == structureFragment { + // The setting says every element, and a fragment cannot hold eight of + // them. Saying so here is what keeps the manifest from claiming a + // catalogue the file does not carry. + p.Notes = append(p.Notes, format.Note{ + Code: "elements_left_out", + Detail: core.Says("html.FragmentLeavesOut", "A fragment has no head and no skeleton, so %s are not in it.", + core.A("Elements", core.Joined{Items: pageOnly, And: true})), + }) + } - m := memo{seed: r.Seed, head: prologueFor(shape), shape: shape} + m := memo{seed: r.Seed, head: s.prologue(), settings: s} if r.Label { var note *format.Note - m.head, m.labelLine, note = labelledHead(shape, r, min) + m.head, m.labelLine, note = labelledHead(s, r, min) if note != nil { p.Notes = append(p.Notes, *note) } @@ -219,6 +132,26 @@ func (generator) Plan(r format.Request) (format.Plan, error) { return p, nil } +// belowMinimum is the refusal of a size too small for these settings. +func belowMinimum(s settings, requested, min int64) error { + reason := core.Says("html.MinimumPage", "a page holds a head, a body and whole blocks, and one of each needs that much") + switch { + 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", + 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))) + case s.shape == structureFragment: + reason = core.Says("html.MinimumFragment", "a fragment holds whole blocks, and one of them needs that much") + } + return &format.BelowMinimumError{ + Format: "HTML", + Requested: requested, + Minimum: min, + Reason: reason, + Hint: core.Says("format.AskForBOrMore", "Ask for %d B or more.", core.A("Min", min)), + } +} + // labelledHead works out what a labelled file carries: the head, the visible // heading, and a note instead of both when there is no room beside a whole // block. @@ -226,23 +159,24 @@ func (generator) Plan(r format.Request) (format.Plan, error) { // Split out of Plan when that function reached the crowding threshold. The line // is what a part does rather than how long it is: this answers "does the label // fit and what does it cost", and Plan answers "is this request askable at all". -func labelledHead(shape string, r format.Request, min int64) (head, heading string, note *format.Note) { +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" - head, extra := prologueFor(shape), int64(0) - if shape == structureDocument { + plain := s.prologue() + head, extra := plain, int64(0) + if s.shape == structureDocument { // The title and the heading say the same thing, which is what a page // 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(prologue, emptyTitle, ""+label+"", 1) - extra = int64(len(head) - len(prologue)) + head = strings.Replace(plain, emptyTitle, ""+label+"", 1) + extra = int64(len(head) - len(plain)) } if extra+int64(len(heading))+min <= r.Bytes { return head, heading, nil } - return prologueFor(shape), "", &format.Note{ + return plain, "", &format.Note{ Code: "label_omitted", Detail: core.Says("format.TheLabelNeedsBAndThis", "The label needs %d B and this file has no room for it beside a whole block. Its name and the manifest still identify it.", core.A("Length", len(heading))), } @@ -254,122 +188,26 @@ func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error { return core.Defect(fmt.Errorf("html: the plan was not produced by this generator")) } - head := m.head + m.labelLine + // The label sits outside the levels of nesting, so a reader that drops + // elements past its depth - libxml2 does, measured on 2026-10-14 - still + // shows which file it read. + head := m.head + m.labelLine + m.settings.opening() if err := core.WriteAll(w, []byte(head)); err != nil { return err } rng := core.NewRand(m.seed) - return core.FillRecords(ctx, w, rng, p.Bytes-int64(len(head)), blocksFor(m.shape)) -} - -// blocks builds the body. A natural record is one complete block element and -// the closing record is a paragraph stretched to land the byte count. -// -// Whole blocks only, for the same reason Markdown writes whole blocks: a table -// or a list cut in the middle still renders, and it says something other than -// it meant. -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 -} - -// 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 (blocks) Append(dst []byte, rng *rand.Rand) []byte { - switch rng.IntN(5) { - case 0: - dst = append(dst, "

    "...) - dst = appendPhrase(dst, rng, 2+rng.IntN(3)) - return append(dst, "

    \n"...) - case 1: - dst = append(dst, "
      \n"...) - for i, n := 0, 2+rng.IntN(3); i < n; i++ { - dst = append(dst, "
    • "...) - dst = appendPhrase(dst, rng, 3+rng.IntN(4)) - dst = append(dst, "
    • \n"...) - } - return append(dst, "
    \n"...) - case 2: - dst = append(dst, "\n\n"...) - for i, n := 0, 2+rng.IntN(3); i < n; i++ { - dst = append(dst, "\n"...) - } - return append(dst, "
    idfieldvalue
    "...) - dst = strconv.AppendInt(dst, int64(i+1), 10) - dst = append(dst, ""...) - dst = append(dst, words[rng.IntN(len(words))]...) - dst = append(dst, ""...) - dst = append(dst, words[rng.IntN(len(words))]...) - dst = append(dst, "
    \n"...) - 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 = appendPhrase(dst, rng, 4+rng.IntN(4)) - dst = append(dst, " & "...) - dst = appendPhrase(dst, rng, 2) - return append(dst, "

    \n"...) - default: - dst = append(dst, paraOpen...) - dst = appendPhrase(dst, rng, 12+rng.IntN(12)) - return append(dst, paraClose...) - } -} - -// Discard has nothing to put back. A block carries no state from one to the -// next, so throwing one away leaves no trace to undo. -func (blocks) Discard() {} - -func (b blocks) AppendExact(dst []byte, rng *rand.Rand, n int64) []byte { - start := len(dst) - dst = append(dst, paraOpen...) - used := int64(len(dst)-start) + int64(len(b.tail)) - dst = appendFiller(dst, n-used) - return append(dst, b.tail...) -} - -func appendPhrase(dst []byte, rng *rand.Rand, n int) []byte { - for i := 0; i < n; i++ { - if i > 0 { - dst = append(dst, ' ') - } - dst = append(dst, words[rng.IntN(len(words))]...) - } - return dst -} - -// appendFiller writes exactly n bytes of paragraph text out of readable words. -// -// It never emits an ampersand or an angle bracket, the two characters that -// would have to be escaped - an escape would make the text longer than the -// count asked for. -// appendFiller stretches the closing paragraph to the byte. -func appendFiller(dst []byte, n int64) []byte { - return core.AppendFiller(dst, words, n, nil) + return core.FillRecords(ctx, w, rng, p.Bytes-int64(len(head)), m.settings.blocks()) } // minimumBytes is the smallest whole page, which is what the registry declares. -// Every shape answers for its own, the way the JSON layouts do. -func minimumBytes() int64 { return minimumFor(structureDocument) } - -// minimumFor is the skeleton of one shape and one whole block, computed rather -// than written down so it cannot drift away from the template. -func minimumFor(shape string) int64 { - return int64(len(prologueFor(shape))) + blocksFor(shape).Shortest() -} - -// words is the filler vocabulary. English by default, like the rest of the text -// group. -var words = []string{ - "account", "anchor", "banner", "browser", "caption", "content", "control", - "default", "element", "feature", "footer", "gallery", "handler", "header", - "heading", "inline", "layout", "listing", "margin", "marker", "message", - "module", "navigation", "notice", "option", "padding", "preview", "profile", - "section", "selector", "sidebar", "summary", "template", "toolbar", - "tooltip", "viewport", "widget", "wrapper", +// Every combination of settings answers for its own, the way the JSON layouts +// do. +func minimumBytes() int64 { return minimumFor(settings{shape: structureDocument}) } + +// minimumFor is the prologue, what the settings open the body with and one +// 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() } diff --git a/internal/format/htmlfile/page.go b/internal/format/htmlfile/page.go new file mode 100644 index 00000000..409623aa --- /dev/null +++ b/internal/format/htmlfile/page.go @@ -0,0 +1,98 @@ +package htmlfile + +import "strings" + +// How a page is laid out from its settings, split out of html.go on 2026-10-14. +// The order is the one measured and decided in docs/HTML-2-2026-10-14.md +// section 12: the mark, the skeleton and the head, the label, the levels of +// 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 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" +) + +// 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 +// bytes so no escape in this file can be turned into something else on the way. +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. +func (s settings) prologue() string { + p := "" + if s.shape == structureDocument { + p = headOpen + s.catalogueHeadFor() + headClose + } + if s.bom { + p = byteOrderMark + p + } + return p +} + +// opening is what follows the label: the levels of nesting, then the paragraphs +// in other scripts and the catalogue. Built when the file is written and never +// 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() +} + +// 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())) +} + +// blocks is the body builder for these settings. +// +// The settings have to reach the BUILDER and not only the prologue, and that is +// the part of this easy to miss: the bytes that close the levels, the body and +// the document sit in the last RECORD rather than in a footer. A change that +// swapped only the prologue would end a fragment with , or a page +// 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) + if s.shape == structureDocument { + tail += bodyClose + } + return blocks{tail: tail} +} + +// 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) + if s.shape == structureDocument { + n += len(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 } diff --git a/internal/format/htmlfile/settings.go b/internal/format/htmlfile/settings.go new file mode 100644 index 00000000..e2302b58 --- /dev/null +++ b/internal/format/htmlfile/settings.go @@ -0,0 +1,174 @@ +package htmlfile + +import ( + "strconv" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/format/textenc" +) + +// The settings of a page, and what each of them takes. Split out of html.go on +// 2026-10-14, when four settings joined the one there was and the file would +// have gone past the size the shape guard allows. The measurements behind every +// number below are in docs/HTML-2-2026-10-14.md section 11. + +const ( + settingStructure = "structure" + structureDocument = "document" + structureFragment = "fragment" + + settingNesting = "nesting" + // maxNesting is past every threshold measured on 2026-10-14 - lxml losing + // the content at about 250, Chromium at 511, jsdom, parse5 and DOMPurify + // from about 2 000 in a fresh process - and far enough past them that + // html5lib, which has no threshold, takes minutes. A ceiling below a + // reader's threshold would leave that threshold impossible to order. + maxNesting = 100000 + + settingCharacters = "characters" + charactersASCII = "ascii" + charactersInternational = "international" + + settingElements = "elements" + elementsBasic = "basic" + elementsEvery = "every" +) + +// settings is what one page was asked for. +type settings struct { + shape string + // nesting is how many div elements the content sits inside. + nesting int + // international adds the paragraphs in other scripts, and every the + // catalogue of every element. + international, every bool + // bom is a byte order mark in front of everything else. + bom bool +} + +// properties is the declaration of every setting, in the order both surfaces +// show them. +func properties() []format.Property { + return []format.Property{{ + Name: settingStructure, Kind: format.PropertyChoice, + Choices: []string{structureDocument, structureFragment}, + Default: structureDocument, + Detail: "Whether the file is a whole page or only the blocks that would sit inside one. A fragment has no doctype, no html element and no body, which is what a content field or the body of an email really holds. It is far smaller, so the smallest fragment sits well below the smallest page.", + }, { + Name: settingNesting, Kind: format.PropertyInt, + Min: 0, Max: maxNesting, + Default: "0", + Detail: "How many div elements the content sits inside, each one inside the last. 0 puts it straight in the body, and the label stays outside so the page still says what it is. " + + "Chromium keeps 511 levels and moves the deeper ones out, and the Nu HTML Checker calls a page that deep an error. " + + "lxml drops the content of a page nested about 250 deep and keeps only the label, and jsdom, parse5 and DOMPurify can run out of stack writing out a page from about 2 000 deep.", + }, { + Name: settingCharacters, Kind: format.PropertyChoice, + Choices: []string{charactersASCII, charactersInternational}, + Default: charactersASCII, + Detail: "Whether the page carries text outside ASCII. international adds paragraphs in Polish, Russian, Japanese and Arabic, the Arabic right to left, " + + "with emoji, a letter written as two code points and named and numbered entities. A program without a Japanese or Arabic font shows boxes in their place.", + }, { + Name: settingElements, Kind: format.PropertyChoice, + Choices: []string{elementsBasic, elementsEvery}, + Default: elementsBasic, + Detail: "Which elements the page uses. basic is headings, paragraphs, lists, tables and quotes. " + + "every adds a block that uses every element of the HTML standard at least once, all 22 kinds of input and small pictures and a sound inside the page, with no script and nothing fetched from a network. " + + "A fragment has no head, so it leaves out the eight elements that only a whole page holds.", + }, textenc.Axes(textenc.SettingBOM)[0]} +} + +// settingsOf reads the settings. +// +// A value outside the declared set has already been refused by the registry, +// which checks every format against its declaration in one place. These +// branches stay for the same reason the CSV dialect and the text encoding keep +// theirs: this function is callable directly, a guard is such a caller, and a +// generator that trusts its input is one registry change away from writing a +// file nobody ordered. +func settingsOf(props map[string]string) (settings, error) { + s := settings{shape: structureDocument} + if v, ok := choice(props, settingStructure, structureDocument, structureFragment); ok { + s.shape = v + } else { + return settings{}, refused(props, settingStructure, core.Says("html.HasToBeStructure", "it has to be %s or %s", + core.A("Document", core.Choice{Of: settingStructure, Value: structureDocument}), + core.A("Fragment", core.Choice{Of: settingStructure, Value: structureFragment}))) + } + if v, ok := choice(props, settingCharacters, charactersASCII, charactersInternational); ok { + s.international = v == charactersInternational + } else { + return settings{}, refused(props, settingCharacters, core.Says("html.HasToBeCharacters", "it has to be %s or %s", + core.A("ASCII", core.Choice{Of: settingCharacters, Value: charactersASCII}), + core.A("International", core.Choice{Of: settingCharacters, Value: charactersInternational}))) + } + if v, ok := choice(props, settingElements, elementsBasic, elementsEvery); ok { + s.every = v == elementsEvery + } else { + return settings{}, refused(props, settingElements, core.Says("html.HasToBeElements", "it has to be %s or %s", + core.A("Basic", core.Choice{Of: settingElements, Value: elementsBasic}), + core.A("Every", core.Choice{Of: settingElements, Value: elementsEvery}))) + } + var err error + if s.nesting, err = nestingOf(props); err != nil { + return settings{}, err + } + codec, err := textenc.Parse("html", props) + if err != nil { + return settings{}, err + } + s.bom = codec.HasBOM() + return s, nil +} + +// choice reads a setting of two values, the first of them the default, and +// says whether the value is one of them. +func choice(props map[string]string, key, first, second string) (string, bool) { + v, ok := props[key] + if !ok || v == "" { + return first, true + } + return v, v == first || v == second +} + +// refused is a value outside the two a setting takes. The sentence comes from +// the caller, written out there, because a translator finds a sentence by its +// id and an id passed along as a value is one nobody can find. +func refused(props map[string]string, key string, reason core.Said) error { + return &format.PropertyValueError{Format: "html", Key: key, Value: props[key], Reason: reason} +} + +// nestingOf reads how deep the content sits. +func nestingOf(props map[string]string) (int, error) { + v, ok := props[settingNesting] + if !ok || v == "" { + return 0, nil + } + n, err := strconv.Atoi(v) + if err != nil || n < 0 || n > maxNesting { + return 0, &format.PropertyValueError{ + Format: "html", Key: settingNesting, Value: v, + Reason: core.Says("html.HasToBeNesting", "it has to be a whole number from 0 to %d", core.A("Max", maxNesting)), + } + } + return n, nil +} + +// manifest is what the manifest records about the settings, every one of them +// whatever was asked, so a reader never has to know a default. +func (s settings) manifest() map[string]any { + chars, elements := charactersASCII, elementsBasic + if s.international { + chars = charactersInternational + } + if s.every { + elements = elementsEvery + } + return map[string]any{ + settingStructure: s.shape, + settingNesting: s.nesting, + settingCharacters: chars, + settingElements: elements, + textenc.SettingBOM: s.bom, + } +} diff --git a/internal/guard/generatorbytes_test.go b/internal/guard/generatorbytes_test.go index 4c089648..7dea328f 100644 --- a/internal/guard/generatorbytes_test.go +++ b/internal/guard/generatorbytes_test.go @@ -395,7 +395,7 @@ func goldenCases() map[string]engine.Target { // have to agree to the byte. "targz_past_the_comment_limit": {ID: "g", Format: "targz", Sizes: engine.Uniform(1, 262144), Label: true}, } - for _, more := range []map[string]engine.Target{mapGoldenCases(), mailGoldenCases()} { + for _, more := range []map[string]engine.Target{mapGoldenCases(), mailGoldenCases(), pageGoldenCases()} { for name, target := range more { cases[name] = target } @@ -440,6 +440,27 @@ func mapGoldenCases() map[string]engine.Target { } } +// pageGoldenCases are the golden cases of the html settings that came on +// 2026-10-14, a table of their own for the reason mailGoldenCases gives. +func pageGoldenCases() map[string]engine.Target { + return map[string]engine.Target{ + // One case for each path the bytes take: levels of nesting with the + // paragraphs in other scripts, the catalogue of every element alone, a + // fragment with a mark and levels at an odd size - where nothing closes + // the body after the levels - and everything at once past the depth + // Chromium flattens. Each read by the structural checker, told every + // setting, when its value was recorded. + "html_8kib_nested_international": {ID: "g", Format: "html", Sizes: engine.Uniform(1, 8192), Label: true, + Properties: map[string]string{"nesting": "3", "characters": "international"}}, + "html_16kib_every": {ID: "g", Format: "html", Sizes: engine.Uniform(1, 16384), Label: true, + Properties: map[string]string{"elements": "every"}}, + "html_odd_fragment_bom_nested": {ID: "g", Format: "html", Sizes: engine.Uniform(1, 8193), Label: true, + Properties: map[string]string{"structure": "fragment", "bom": "true", "nesting": "2"}}, + "html_64kib_everything": {ID: "g", Format: "html", Sizes: engine.Uniform(1, 65536), Label: true, + Properties: map[string]string{"nesting": "600", "characters": "international", "elements": "every", "bom": "true"}}, + } +} + // mailGoldenCases are the golden cases of the eml format, a table of their own // so goldenCases stays a length a person can read. func mailGoldenCases() map[string]engine.Target { diff --git a/internal/guard/htmlcatalogue_test.go b/internal/guard/htmlcatalogue_test.go new file mode 100644 index 00000000..4f9bb026 --- /dev/null +++ b/internal/guard/htmlcatalogue_test.go @@ -0,0 +1,144 @@ +package guard + +import ( + "bytes" + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" +) + +// What elements=every, characters=international and bom put into a page, each +// read from the bytes. The lists are written here from the standard and from +// the decision, not imported from the generator, so the two cannot agree on a +// list that lost a name. + +// htmlStandardElements is the index "List of elements" of the HTML standard, +// read on 2026-10-14 (docs/HTML-2-2026-10-14.md section 11.1): 115 names. +var htmlStandardElements = strings.Fields(`a abbr address area article aside audio b +base bdi bdo blockquote body br button canvas caption cite code col colgroup data +datalist dd del details dfn dialog div dl dt em embed fieldset figcaption figure +footer form h1 h2 h3 h4 h5 h6 head header hgroup hr html i iframe img input ins +kbd label legend li link main map mark math menu meta meter nav noscript object +ol optgroup option output p picture pre progress q rp rt ruby s samp script +search section select selectedcontent slot small source span strong style sub +summary sup svg table tbody td template textarea tfoot th thead time title tr +track u ul var video wbr`) + +// htmlInputTypes is the 22 keywords of the type attribute of input, read from +// the standard on the same day. +var htmlInputTypes = strings.Fields(`hidden text search tel url email password +date month week time datetime-local number range color checkbox radio file +submit image reset button`) + +// htmlPageOnly is what only a whole page holds: its skeleton and its head. +var htmlPageOnly = map[string]bool{"base": true, "body": true, "head": true, "html": true, + "link": true, "meta": true, "style": true, "title": true} + +// TestEveryElementOfTheStandardIsInTheCatalogue is the claim of elements=every. +// +// Every name of the standard but script (decision D3), and in a fragment every +// name but the eight that only a page holds - with a note saying so, since a +// setting called every that quietly leaves eight out would be the manifest +// claiming a file that is not there. The page also reaches no network: the +// detail promises it, and a picture by address would make a browser fetch it. +func TestEveryElementOfTheStandardIsInTheCatalogue(t *testing.T) { + d, err := format.Get("html") + if err != nil { + t.Fatal(err) + } + if len(htmlStandardElements) != 115 || len(htmlInputTypes) != 22 { + t.Fatalf("the lists here hold %d names and %d kinds of input, and the standard read on 2026-10-14 has 115 and 22", + len(htmlStandardElements), len(htmlInputTypes)) + } + for _, shape := range []string{"document", "fragment"} { + props := map[string]string{"elements": "every", "structure": shape} + body := writeEncoded(t, "html", 32768, props) + // Opening tags only: an element whose opening tag was swapped for + // another one and whose closing tag stayed behind is not in the page. + names := map[string]bool{} + for _, m := range htmlNamedTag.FindAllSubmatch(body, -1) { + if len(m[1]) == 0 { + names[strings.ToLower(string(m[2]))] = true + } + } + for _, name := range htmlStandardElements { + want := name != "script" && !(shape == "fragment" && htmlPageOnly[name]) + if want != names[name] { + t.Errorf("%s: the catalogue holds <%s> %v, and it should be %v", shape, name, names[name], want) + } + } + for _, kind := range htmlInputTypes { + if !bytes.Contains(body, []byte(`Zażółć gęślą jaźń.

    `, + `

    Это текст на русском языке.

    `, + `

    これは日本語のテキストです。

    `, + `

    هذا نص باللغة العربية.

    `, + string(rune(0x1F600)), + string([]rune{0x1F469, 0x200D, 0x1F4BB}), + "e" + string(rune(0x301)), + "  © € € 😀", +} + +// TestInternationalCarriesEveryScriptAndASCIICarriesNone holds both halves: +// the paragraphs are there when they were ordered, and a page that was not +// asked for them is plain ASCII, the catalogue of every element included. +func TestInternationalCarriesEveryScriptAndASCIICarriesNone(t *testing.T) { + for _, shape := range []string{"document", "fragment"} { + body := writeEncoded(t, "html", 16384, map[string]string{"characters": "international", "structure": shape}) + for _, piece := range htmlScripts { + if !bytes.Contains(body, []byte(piece)) { + t.Errorf("%s: characters=international and the page does not carry %+q", shape, piece) + } + } + for _, elements := range []string{"basic", "every"} { + plain := writeEncoded(t, "html", 16384, map[string]string{"elements": elements, "structure": shape}) + if i := bytes.IndexFunc(plain, func(r rune) bool { return r > 0x7e }); i >= 0 { + t.Errorf("%s, elements=%s: characters=ascii and byte %d of the page is outside ASCII", shape, elements, i) + } + } + } +} + +// TestTheHTMLByteOrderMarkIsThereOnlyWhenOrdered holds the mark: in front of +// the doctype of a page and of the label of a fragment, and nowhere when it was +// not ordered. The mark comes from the shared text encoding axis, so its +// declaration is held by familyaxes_test.go - this holds where it lands. +func TestTheHTMLByteOrderMarkIsThereOnlyWhenOrdered(t *testing.T) { + mark := []byte{0xEF, 0xBB, 0xBF} + for shape, next := range map[string]string{"document": "", "fragment": "

    tfg"} { + with := writeEncoded(t, "html", 4096, map[string]string{"bom": "true", "structure": shape}) + if !bytes.HasPrefix(with, append(append([]byte{}, mark...), next...)) { + t.Errorf("%s: bom=true and the file does not open with the mark followed by %q", shape, next) + } + without := writeEncoded(t, "html", 4096, map[string]string{"structure": shape}) + if bytes.Contains(without, mark) { + t.Errorf("%s: no mark was ordered and the file holds one", shape) + } + } +} diff --git a/internal/guard/htmlsettings_test.go b/internal/guard/htmlsettings_test.go new file mode 100644 index 00000000..6f40fd8e --- /dev/null +++ b/internal/guard/htmlsettings_test.go @@ -0,0 +1,209 @@ +package guard + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/oracle" +) + +// HTML gained four settings on 2026-10-14 - nesting, characters, elements and +// bom - measured on eight readers before a line was written +// (docs/HTML-2-2026-10-14.md section 11). Every guard here reads the BYTES, for +// the reason htmlfragment_test.go gives: the tolerant reader beside this one +// accepts anything, and the two that build the deepest trees either flatten +// the levels (Chromium) or drop them with their content (libxml2), so a tree +// would report something other than the file. + +// htmlCombos are the settings the guards below walk, one for each path the +// bytes take and the two shapes of every path that changes the end of a file. +var htmlCombos = []map[string]string{ + {}, + {"nesting": "1"}, + {"nesting": "40", "structure": "fragment"}, + {"characters": "international"}, + {"elements": "every"}, + {"elements": "every", "structure": "fragment"}, + {"bom": "true"}, + {"bom": "true", "structure": "fragment"}, + {"nesting": "600", "characters": "international", "elements": "every", "bom": "true"}, +} + +// htmlClosing is what a file of these settings ends with after its closing +// paragraph, written here from the shape of HTML rather than taken from the +// generator. +func htmlClosing(props map[string]string) string { + n, _ := strconv.Atoi(props["nesting"]) + end := strings.Repeat("\n", n) + if props["structure"] != "fragment" { + end += "\n\n" + } + return end +} + +// TestEveryHTMLSettingAnswersForItsOwnMinimum holds the arithmetic of every +// path. +// +// The floor is pinned from the FILE rather than from the generator's sums: at +// the smallest size a format accepts, the closing paragraph has to be empty - +// one byte of text in it means the floor could have been lower, and one byte +// less has to be refused naming that floor. A floor the format announces and +// then turns down, or one that wastes bytes nobody ordered, both go red here. +func TestEveryHTMLSettingAnswersForItsOwnMinimum(t *testing.T) { + d, err := format.Get("html") + if err != nil { + t.Fatal(err) + } + for _, props := range htmlCombos { + floor := d.SmallestAccepted(format.Request{Seed: 7741, Label: true, Properties: props}) + body := writeEncoded(t, "html", floor, props) + if want := "

    \n" + htmlClosing(props); !bytes.HasSuffix(body, []byte(want)) { + t.Errorf("%v: the smallest file, %d B, does not end in an empty closing paragraph and %q, so its floor is not its own", + props, floor, want) + } + _, err := d.Generator.Plan(format.Request{Bytes: floor - 1, Seed: 7741, Label: true, Properties: props}) + var below *format.BelowMinimumError + if !errors.As(err, &below) || below.Minimum != floor { + t.Errorf("%v: one byte under the floor of %d B was answered with %v, not a refusal naming that floor", + props, floor, err) + } + } +} + +// htmlNamedTag is one tag, opening or closing, with a hyphen allowed in its name +// since the catalogue carries a custom element. +var htmlNamedTag = regexp.MustCompile(`<(/?)([a-zA-Z][a-zA-Z0-9-]*)[^>]*>`) + +// htmlDivDepth is how deep div elements go in a page, read tag by tag. +func htmlDivDepth(body []byte) int { + depth, deepest := 0, 0 + for _, m := range htmlNamedTag.FindAllSubmatch(body, -1) { + if string(m[2]) != "div" { + continue + } + if len(m[1]) == 0 { + depth++ + deepest = max(deepest, depth) + } else { + depth-- + } + } + return deepest +} + +// TestNestingPutsTheContentThatManyLevelsDeep is the claim of nesting. +// +// It ASKS how deep the file goes rather than assuming the setting reached the +// writer: the levels open after the label, close after the last paragraph, and +// nothing else in a basic page is a div, so the deepest div is the number +// ordered and not one more. The label is outside them - a reader that drops +// what it cannot hold keeps the line saying which file it read. +func TestNestingPutsTheContentThatManyLevelsDeep(t *testing.T) { + for _, n := range []int{1, 3, 600} { + props := map[string]string{"nesting": strconv.Itoa(n)} + body := writeEncoded(t, "html", 65536, props) + if got := htmlDivDepth(body); got != n { + t.Errorf("nesting=%d: the page goes %d div elements deep", n, got) + } + label := bytes.Index(body, []byte("\n

    tfg")) + levels := bytes.Index(body, []byte(strings.Repeat("
    \n", n))) + if label < 0 || levels < label { + t.Errorf("nesting=%d: the label is not in front of the levels, so a reader that drops deep elements drops it too", n) + } + if !bytes.HasSuffix(body, []byte("

    \n"+htmlClosing(props))) { + t.Errorf("nesting=%d: the page does not close its levels after its last paragraph", n) + } + } +} + +// TestEveryHTMLSettingIsRecordedInTheManifest holds what a test reads back. +// +// Every setting is recorded whatever was asked, so a reader of the manifest +// never has to know a default - and a recorded value is the one the file was +// written with, not the one that was typed. +func TestEveryHTMLSettingIsRecordedInTheManifest(t *testing.T) { + d, err := format.Get("html") + if err != nil { + t.Fatal(err) + } + for _, c := range []struct { + props map[string]string + want map[string]any + }{ + {nil, map[string]any{"structure": "document", "nesting": 0, "characters": "ascii", "elements": "basic", "bom": false}}, + {map[string]string{"structure": "fragment", "nesting": "7", "characters": "international", "elements": "every", "bom": "true"}, + map[string]any{"structure": "fragment", "nesting": 7, "characters": "international", "elements": "every", "bom": true}}, + } { + p, err := d.Generator.Plan(format.Request{Bytes: 65536, Seed: 7741, Label: true, Properties: c.props}) + if err != nil { + t.Fatal(err) + } + for k, v := range c.want { + if p.Properties[k] != v { + t.Errorf("%v: the manifest records %s as %v, not %v", c.props, k, p.Properties[k], v) + } + } + } +} + +// TestTheDefaultHTMLSettingsAreTheBytesItAlwaysWrote is the way back, for the +// four settings at once: saying nothing and saying every default out loud are +// two routes through the parser and have to meet. +func TestTheDefaultHTMLSettingsAreTheBytesItAlwaysWrote(t *testing.T) { + spoken := map[string]string{"nesting": "0", "characters": "ascii", "elements": "basic", "bom": "false"} + for _, size := range []int64{118, 119, 4096} { + if !bytes.Equal(writeEncoded(t, "html", size, nil), writeEncoded(t, "html", size, spoken)) { + t.Errorf("at %d B: saying nothing and saying every default produce different bytes", size) + } + } +} + +// TestTheStructuralCheckIsToldEveryHTMLSetting is the canary for the checker. +// +// Every other guard hands it a file of the settings it asked for, so all of +// them would stay green if it stopped looking at any one of them. Each row +// below is a file of one value handed over as the other. +func TestTheStructuralCheckIsToldEveryHTMLSetting(t *testing.T) { + dir := t.TempDir() + for i, c := range []struct{ made, told map[string]string }{ + {map[string]string{"nesting": "3"}, map[string]string{"nesting": "2"}}, + {map[string]string{"nesting": "3"}, map[string]string{"nesting": "4"}}, + {map[string]string{"characters": "international"}, map[string]string{"characters": "ascii"}}, + {map[string]string{}, map[string]string{"characters": "international"}}, + {map[string]string{"elements": "every"}, map[string]string{"elements": "basic"}}, + {map[string]string{}, map[string]string{"elements": "every"}}, + {map[string]string{"bom": "true"}, map[string]string{"bom": "false"}}, + {map[string]string{}, map[string]string{"bom": "true"}}, + } { + path := filepath.Join(dir, strconv.Itoa(i)+".html") + if err := os.WriteFile(path, writeEncoded(t, "html", 32768, c.made), 0o600); err != nil { + t.Fatal(err) + } + honest := oracle.Strict("html", path, htmlSettingWords(c.made)...) + if !honest.Available { + t.Skip("the structural check needs python") + } + if honest.Err != nil { + t.Fatalf("%v handed over as itself was refused: %v", c.made, honest.Err) + } + if res := oracle.Strict("html", path, htmlSettingWords(c.told)...); res.Err == nil { + t.Errorf("a page made with %v handed over as %v was called correct", c.made, c.told) + } + } +} + +// htmlSettingWords is settings as the checker takes them. +func htmlSettingWords(props map[string]string) []string { + out := make([]string, 0, len(props)) + for k, v := range props { + out = append(out, k+"="+v) + } + return out +} diff --git a/internal/guard/layers_test.go b/internal/guard/layers_test.go index 436e2444..c4e56757 100644 --- a/internal/guard/layers_test.go +++ b/internal/guard/layers_test.go @@ -171,7 +171,7 @@ var sameLayerAllowed = map[string][]string{ "internal/format/yamlfile": {"internal/format"}, "internal/format/tomlfile": {"internal/format", "internal/format/textenc"}, "internal/format/xmlfile": {"internal/format", "internal/format/textenc"}, - "internal/format/htmlfile": {"internal/format"}, + "internal/format/htmlfile": {"internal/format", "internal/format/textenc"}, "internal/format/svgfile": {"internal/format", "internal/format/imagedim"}, "internal/format/bmp": {"internal/format", "internal/format/imagelabel", "internal/format/imagedim"}, "internal/format/opc": {"internal/format"}, diff --git a/internal/guard/testdata/generator-golden.json b/internal/guard/testdata/generator-golden.json index 3dd764bd..91e48be4 100644 --- a/internal/guard/testdata/generator-golden.json +++ b/internal/guard/testdata/generator-golden.json @@ -57,10 +57,26 @@ "sha256": "659e48806e3abe402148f8246921769d66f3ea9d2f73e94888a3fb7abcf76977", "measured_on": "2026-08-29" }, + "html_16kib_every": { + "bytes": 16384, + "sha256": "68b1ebd280622c1aa3489480267742e4c3612f4e7c76ac9bac67b78b2948943d" + }, + "html_64kib_everything": { + "bytes": 65536, + "sha256": "716512ac2db8bdadafed56cd909a60d7c4de4c2d31ed0516eee9a9f5b5a6df5c" + }, "html_8kib": { "bytes": 8192, "sha256": "7fcd7bd8fa49fb5e7392ef33d8f1537c96dabb2157bbda7cfe2eba75c02513a7" }, + "html_8kib_nested_international": { + "bytes": 8192, + "sha256": "26fe8e7ff4be5165778b11aef372de12c39a07fcb1271e9ef29701ebb2ac3bbd" + }, + "html_odd_fragment_bom_nested": { + "bytes": 8193, + "sha256": "9dde31f7afb6857f78283ccc516ea3f609d31d60f08fa19f335f869aa0f494f7" + }, "ico_32kib": { "bytes": 32768, "sha256": "da019bf067004ef7290aca46a5e6d68dd4d0c77bf419511a01166ff17e425f2b", diff --git a/internal/gui/text/locale/registry/en.json b/internal/gui/text/locale/registry/en.json index 2b72043f..128817ba 100644 --- a/internal/gui/text/locale/registry/en.json +++ b/internal/gui/text/locale/registry/en.json @@ -179,6 +179,16 @@ "hash": "sha256-a116c9ed46d6", "other": "plain" }, + "Choice.characters.ascii": { + "description": "One entry of the characters list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-c543ece81605", + "other": "ascii" + }, + "Choice.characters.international": { + "description": "One entry of the characters list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-1e3bb894756f", + "other": "international" + }, "Choice.charset.iso-8859-2": { "description": "One entry of the charset list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", "hash": "sha256-d7b4209b5c03", @@ -254,6 +264,16 @@ "hash": "sha256-7508386a2056", "other": "tab" }, + "Choice.elements.basic": { + "description": "One entry of the elements list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-fbb7b5bf4ca3", + "other": "basic" + }, + "Choice.elements.every": { + "description": "One entry of the elements list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-66a424a9a958", + "other": "every" + }, "Choice.embed.bmp": { "description": "One entry of the embed list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", "hash": "sha256-7685a5cb2b57", @@ -1274,6 +1294,26 @@ "hash": "sha256-3085ac44f0e0", "other": "How wide the picture is. Left out, a size is chosen that fits the bytes you asked for." }, + "Detail.format/html.bom": { + "description": "The sentence under the bom setting of the html format, after what it takes.", + "hash": "sha256-a2d6a88583d6", + "other": "Whether the file opens with a byte order mark. A reader that has to guess the encoding needs one, and a reader that does not expect it shows it as stray characters at the start of the file." + }, + "Detail.format/html.characters": { + "description": "The sentence under the characters setting of the html format, after what it takes.", + "hash": "sha256-846c2063c0e0", + "other": "Whether the page carries text outside ASCII. international adds paragraphs in Polish, Russian, Japanese and Arabic, the Arabic right to left, with emoji, a letter written as two code points and named and numbered entities. A program without a Japanese or Arabic font shows boxes in their place." + }, + "Detail.format/html.elements": { + "description": "The sentence under the elements setting of the html format, after what it takes.", + "hash": "sha256-ad89039f3061", + "other": "Which elements the page uses. basic is headings, paragraphs, lists, tables and quotes. every adds a block that uses every element of the HTML standard at least once, all 22 kinds of input and small pictures and a sound inside the page, with no script and nothing fetched from a network. A fragment has no head, so it leaves out the eight elements that only a whole page holds." + }, + "Detail.format/html.nesting": { + "description": "The sentence under the nesting setting of the html format, after what it takes.", + "hash": "sha256-cac70df12b4b", + "other": "How many div elements the content sits inside, each one inside the last. 0 puts it straight in the body, and the label stays outside so the page still says what it is. Chromium keeps 511 levels and moves the deeper ones out, and the Nu HTML Checker calls a page that deep an error. lxml drops the content of a page nested about 250 deep and keeps only the label, and jsdom, parse5 and DOMPurify can run out of stack writing out a page from about 2 000 deep." + }, "Detail.format/html.structure": { "description": "The sentence under the structure setting of the html format, after what it takes.", "hash": "sha256-40b00d44ba38", @@ -2079,6 +2119,11 @@ "hash": "sha256-4c8906cf76f5", "other": "Channels" }, + "Label.characters": { + "description": "The name beside the box of a setting a recipe writes as characters.", + "hash": "sha256-6fac39e2845b", + "other": "Characters" + }, "Label.charset": { "description": "The name beside the box of a setting a recipe writes as charset.", "hash": "sha256-60c95b80aa06", @@ -2139,6 +2184,11 @@ "hash": "sha256-4fc52a3c4c55", "other": "Duration" }, + "Label.elements": { + "description": "The name beside the box of a setting a recipe writes as elements.", + "hash": "sha256-e1ff7a906103", + "other": "Elements" + }, "Label.embed": { "description": "The name beside the box of a setting a recipe writes as embed.", "hash": "sha256-7512561d2f68", @@ -2324,6 +2374,11 @@ "hash": "sha256-e8ce5dcaf408", "other": "Modified" }, + "Label.nesting": { + "description": "The name beside the box of a setting a recipe writes as nesting.", + "hash": "sha256-f342629fc0e1", + "other": "Nesting" + }, "Label.orientation": { "description": "The name beside the box of a setting a recipe writes as orientation.", "hash": "sha256-e3d11242200f", diff --git a/internal/gui/text/locale/registry/pl.json b/internal/gui/text/locale/registry/pl.json index a933b133..5d571807 100644 --- a/internal/gui/text/locale/registry/pl.json +++ b/internal/gui/text/locale/registry/pl.json @@ -35,6 +35,8 @@ "Choice.body.alternative": { "hash": "sha256-27747689baf9", "other": "tekst i html" }, "Choice.body.html": { "hash": "sha256-e5e239560bc1", "other": "html" }, "Choice.body.plain": { "hash": "sha256-a116c9ed46d6", "other": "zwykły tekst" }, + "Choice.characters.ascii": { "hash": "sha256-c543ece81605", "other": "ascii" }, + "Choice.characters.international": { "hash": "sha256-1e3bb894756f", "other": "międzynarodowe" }, "Choice.charset.iso-8859-2": { "hash": "sha256-d7b4209b5c03", "other": "iso-8859-2" }, "Choice.charset.utf-8": { "hash": "sha256-5cf1fa8d836a", "other": "utf-8" }, "Choice.charset.windows-1250": { "hash": "sha256-5cfde86a4658", "other": "windows-1250" }, @@ -50,6 +52,8 @@ "Choice.delimiter.pipe": { "hash": "sha256-400829473916", "other": "kreska pionowa" }, "Choice.delimiter.semicolon": { "hash": "sha256-1e69fcfadd9d", "other": "średnik" }, "Choice.delimiter.tab": { "hash": "sha256-7508386a2056", "other": "tabulator" }, + "Choice.elements.basic": { "hash": "sha256-fbb7b5bf4ca3", "other": "podstawowe" }, + "Choice.elements.every": { "hash": "sha256-66a424a9a958", "other": "wszystkie" }, "Choice.embed.bmp": { "hash": "sha256-7685a5cb2b57", "other": "bmp" }, "Choice.embed.png": { "hash": "sha256-8f8cbb7dcf46", "other": "png" }, "Choice.encoding.utf-16be": { "hash": "sha256-43b2bae67086", "other": "utf-16be" }, @@ -254,6 +258,10 @@ "Detail.format/gif.frames": { "hash": "sha256-d2291fedd06c", "other": "Ile klatek ma animacja. Ustaw 1, żeby dostać nieruchomy obraz." }, "Detail.format/gif.height": { "hash": "sha256-4b336c279584", "other": "Jak wysoki jest obraz. Gdy pole zostaje puste, wybierany jest rozmiar, który mieści się w zamówionych bajtach." }, "Detail.format/gif.width": { "hash": "sha256-3085ac44f0e0", "other": "Jak szeroki jest obraz. Gdy pole zostaje puste, wybierany jest rozmiar, który mieści się w zamówionych bajtach." }, + "Detail.format/html.bom": { "hash": "sha256-a2d6a88583d6", "other": "Czy plik zaczyna się od znacznika kolejności bajtów (BOM). Czytnik, który musi zgadywać kodowanie, go potrzebuje, a czytnik, który się go nie spodziewa, pokazuje go jako obce znaki na początku pliku." }, + "Detail.format/html.characters": { "hash": "sha256-846c2063c0e0", "other": "Czy strona zawiera tekst spoza ASCII. międzynarodowe dokłada akapity po polsku, rosyjsku, japońsku i arabsku, arabski od prawej do lewej, z emoji, literą zapisaną dwoma punktami kodowymi oraz encjami nazwanymi i liczbowymi. Program bez fontu japońskiego albo arabskiego pokazuje w ich miejscu kwadraty." }, + "Detail.format/html.elements": { "hash": "sha256-ad89039f3061", "other": "Których elementów używa strona. podstawowe to nagłówki, akapity, listy, tabele i cytaty. wszystkie dokłada blok, który używa każdego elementu standardu HTML co najmniej raz, wszystkich 22 rodzajów pola input oraz małych obrazków i dźwięku zapisanych w samej stronie, bez skryptu i bez niczego pobieranego z sieci. Fragment nie ma głowy, więc pomija osiem elementów, które ma tylko cała strona." }, + "Detail.format/html.nesting": { "hash": "sha256-cac70df12b4b", "other": "W ilu elementach div, jeden w drugim, siedzi treść. 0 umieszcza ją wprost w body, a etykieta zostaje na zewnątrz, więc strona dalej mówi, czym jest. Chromium zachowuje 511 poziomów i wyjmuje głębsze na zewnątrz, a Nu HTML Checker uznaje tak głęboką stronę za błąd. lxml gubi treść strony zagnieżdżonej na około 250 poziomów i zostawia tylko etykietę, a jsdom, parse5 i DOMPurify mogą wyczerpać stos przy zapisie strony od około 2 000 poziomów." }, "Detail.format/html.structure": { "hash": "sha256-40b00d44ba38", "other": "Czy plik jest całą stroną, czy tylko blokami, które stałyby w jej środku. Fragment nie ma doctype, elementu html ani body, czyli tyle, ile naprawdę trzyma pole treści albo treść e-maila. Jest dużo mniejszy, więc najmniejszy fragment jest wyraźnie mniejszy od najmniejszej strony." }, "Detail.format/ico.embed": { "hash": "sha256-1578534c06bf", "other": "Co jest w środku ikony. Bitmapę czyta każda wersja Windows. PNG daje dużo mniejszy plik i wymaga Windows Vista albo nowszego." }, "Detail.format/ico.height": { "hash": "sha256-e2712d04875d", "other": "Jak wysoka jest ikona. Gdy pole zostaje puste, używany jest największy standardowy rozmiar ikony, który się mieści." }, @@ -415,6 +423,7 @@ "Label.bytes": { "hash": "sha256-4d8000301fcc", "other": "Bajty" }, "Label.change_interval": { "hash": "sha256-e99a12a7d82d", "other": "Odstęp zmian obrazu" }, "Label.channels": { "hash": "sha256-4c8906cf76f5", "other": "Kanały" }, + "Label.characters": { "hash": "sha256-6fac39e2845b", "other": "Znaki" }, "Label.charset": { "hash": "sha256-60c95b80aa06", "other": "Zestaw znaków" }, "Label.checksum_file": { "hash": "sha256-6413f83e729a", "other": "Plik sum kontrolnych" }, "Label.columns": { "hash": "sha256-53aade77cd69", "other": "Kolumny" }, @@ -427,6 +436,7 @@ "Label.depth": { "hash": "sha256-f1dbc33978a9", "other": "Głębokość" }, "Label.directory_entries": { "hash": "sha256-7f31f347692c", "other": "Wpisy katalogów" }, "Label.duration": { "hash": "sha256-4fc52a3c4c55", "other": "Czas trwania" }, + "Label.elements": { "hash": "sha256-e1ff7a906103", "other": "Elementy" }, "Label.embed": { "hash": "sha256-7512561d2f68", "other": "Obraz w środku" }, "Label.encoding": { "hash": "sha256-4d25a47625ce", "other": "Kodowanie" }, "Label.encryption": { "hash": "sha256-82f4e719a19c", "other": "Szyfrowanie" }, @@ -464,6 +474,7 @@ "Label.message_depth": { "hash": "sha256-8a18b09b1f37", "other": "Głębokość wiadomości" }, "Label.methods": { "hash": "sha256-8696622f3441", "other": "Metody" }, "Label.modified": { "hash": "sha256-e8ce5dcaf408", "other": "Zmodyfikowano" }, + "Label.nesting": { "hash": "sha256-f342629fc0e1", "other": "Zagnieżdżenie" }, "Label.orientation": { "hash": "sha256-e3d11242200f", "other": "Orientacja" }, "Label.page_size": { "hash": "sha256-bd69e66e00ef", "other": "Rozmiar strony" }, "Label.pages": { "hash": "sha256-9046da16aea9", "other": "Strony" }, diff --git a/internal/gui/text/locale/said/en.json b/internal/gui/text/locale/said/en.json index f5420b1f..07439189 100644 --- a/internal/gui/text/locale/said/en.json +++ b/internal/gui/text/locale/said/en.json @@ -1755,6 +1755,26 @@ "hash": "sha256-6d00afc49eb9", "other": "an outline keeps its middle free for holes only with two points above it and two below - ask for {{.Least}} vertices or more, or for no holes" }, + "html.FragmentLeavesOut": { + "description": "Said by internal/format/htmlfile - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Elements}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-47bec476fafe", + "other": "A fragment has no head and no skeleton, so {{.Elements}} are not in it." + }, + "html.HasToBeCharacters": { + "description": "Said by internal/format/htmlfile - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.ASCII}}, {{.International}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-135f7b4d551c", + "other": "it has to be {{.ASCII}} or {{.International}}" + }, + "html.HasToBeElements": { + "description": "Said by internal/format/htmlfile - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Basic}}, {{.Every}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-d471c09eb934", + "other": "it has to be {{.Basic}} or {{.Every}}" + }, + "html.HasToBeNesting": { + "description": "Said by internal/format/htmlfile - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Max}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-ed9c43e64c22", + "other": "it has to be a whole number from 0 to {{.Max}}" + }, "html.HasToBeStructure": { "description": "Said by internal/format/htmlfile - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Document}}, {{.Fragment}}, written the way the program writes them - keep each spelled exactly that way.", "hash": "sha256-1a83745fa4b2", @@ -1770,6 +1790,11 @@ "hash": "sha256-3745440d090a", "other": "a page holds a head, a body and whole blocks, and one of each needs that much" }, + "html.MinimumWithSettings": { + "description": "Said by internal/format/htmlfile - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Nesting}}, {{.Characters}}, {{.Elements}}, {{.BOM}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-e1528b4b779d", + "other": "the file holds whole blocks and what {{.Nesting}}, {{.Characters}}, {{.Elements}} and {{.BOM}} add to them, and together they need that much" + }, "json.HasToBeStyle": { "description": "Said by internal/format/jsonfile - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Indented}}, {{.Minified}}, {{.RecordPerLine}}, written the way the program writes them - keep each spelled exactly that way.", "hash": "sha256-c1343585d922", diff --git a/internal/gui/text/locale/said/pl.json b/internal/gui/text/locale/said/pl.json index d626f412..618f538a 100644 --- a/internal/gui/text/locale/said/pl.json +++ b/internal/gui/text/locale/said/pl.json @@ -349,9 +349,14 @@ "geojson.EachHoleNeedsSteps": { "hash": "sha256-5774cee2ea85", "other": "każda dziura potrzebuje czterech kroków długości geograficznej, a obrys z dziurami zajmuje najwyżej pół kuli ziemskiej, więc przy precyzji {{.Precision}} ma miejsce na {{.Most}} - poproś o tyle lub mniej albo o wyższą precyzję" }, "geojson.EachPointNeedsAStep": { "hash": "sha256-2cbc901989bd", "other": "każdy punkt potrzebuje własnego kroku długości geograficznej, a przy precyzji {{.Precision}} kula ziemska ma miejsce na {{.Most}} - poproś o tyle lub mniej albo o wyższą precyzję" }, "geojson.HolesNeedSixPoints": { "hash": "sha256-6d00afc49eb9", "other": "obrys zostawia środek wolny na dziury tylko z dwoma punktami nad nim i dwoma pod nim - poproś o co najmniej {{.Least}} punktów albo o brak dziur" }, + "html.FragmentLeavesOut": { "hash": "sha256-47bec476fafe", "other": "Fragment nie ma głowy ani szkieletu strony, więc nie ma w nim elementów {{.Elements}}." }, + "html.HasToBeCharacters": { "hash": "sha256-135f7b4d551c", "other": "musi to być {{.ASCII}} albo {{.International}}" }, + "html.HasToBeElements": { "hash": "sha256-d471c09eb934", "other": "musi to być {{.Basic}} albo {{.Every}}" }, + "html.HasToBeNesting": { "hash": "sha256-ed9c43e64c22", "other": "musi to być liczba całkowita od 0 do {{.Max}}" }, "html.HasToBeStructure": { "hash": "sha256-1a83745fa4b2", "other": "musi to być {{.Document}} albo {{.Fragment}}" }, "html.MinimumFragment": { "hash": "sha256-e716d4f229d7", "other": "fragment zawiera całe bloki, a jeden z nich potrzebuje tyle" }, "html.MinimumPage": { "hash": "sha256-3745440d090a", "other": "strona zawiera nagłówek, treść i całe bloki, a po jednym z nich potrzeba tyle" }, + "html.MinimumWithSettings": { "hash": "sha256-e1528b4b779d", "other": "plik zawiera całe bloki i to, co dokładają do nich {{.Nesting}}, {{.Characters}}, {{.Elements}} i {{.BOM}}, a razem potrzebują tyle" }, "json.HasToBeStyle": { "hash": "sha256-c1343585d922", "other": "musi to być {{.Indented}}, {{.Minified}} albo {{.RecordPerLine}}" }, "jxl.MinimumReason": { "hash": "sha256-8031f7cf3cc6", "other": "najmniejszy obrazek, jaki rysuje ten format, koduje się w najgorszym razie do {{.Coded}} B, kontener wokół niego kosztuje {{.Container}} B, a plik zawsze niesie wolny blok, który kosztuje {{.Box}} B nawet pusty" }, "jxl.MinimumReasonOwn": { "hash": "sha256-08ce5283cdaa", "other": "obrazek {{.Width}}x{{.Height}} w jakości {{.Quality}} koduje się do {{.Coded}} B, kontener wokół niego kosztuje {{.Container}} B, a plik zawsze niesie wolny blok, który kosztuje {{.Box}} B nawet pusty" }, diff --git a/internal/oracle/strict.py b/internal/oracle/strict.py index a53c8213..bed404a8 100644 --- a/internal/oracle/strict.py +++ b/internal/oracle/strict.py @@ -655,6 +655,105 @@ def check_svg(data): ok(f"{shapes} drawable shapes out of {elements} elements") +# The elements of the HTML standard, read from its index "List of elements" on +# 2026-10-14 - 115 names (docs/HTML-2-2026-10-14.md section 11.1). Written here +# from the standard and not imported from the generator, so the two cannot +# agree on a list that lost a name. +HTML_ELEMENTS = """a abbr address area article aside audio b base bdi bdo blockquote +body br button canvas caption cite code col colgroup data datalist dd del details +dfn dialog div dl dt em embed fieldset figcaption figure footer form h1 h2 h3 h4 +h5 h6 head header hgroup hr html i iframe img input ins kbd label legend li link +main map mark math menu meta meter nav noscript object ol optgroup option output +p picture pre progress q rp rt ruby s samp script search section select +selectedcontent slot small source span strong style sub summary sup svg table +tbody td template textarea tfoot th thead time title tr track u ul var video +wbr""".split() +# script is the one the catalogue leaves out on purpose (decision D3), and a +# fragment has no head or skeleton to hold these eight. +HTML_LEFT_OUT = {"script"} +HTML_PAGE_ONLY = {"base", "body", "head", "html", "link", "meta", "style", "title"} +HTML_INPUT_TYPES = """hidden text search tel url email password date month week +time datetime-local number range color checkbox radio file submit image reset +button""".split() + +# What characters=international has to carry, each written here on its own. +HTML_INTERNATIONAL = [ + '

    Zażółć gęślą jaźń.

    ', + '

    Это текст на русском языке.

    ', + '

    これは日本語のテキストです。

    ', + '

    هذا نص باللغة العربية.

    ', + chr(0x1F600), + chr(0x1F469) + chr(0x200D) + chr(0x1F4BB), + "e" + chr(0x301), + "  © € € 😀", +] + + +def html_settings(text, shape, settings): + """What nesting, characters and elements ordered is in the file. + + Read from the TEXT rather than from a tree, for the reason the shape check + gives: a tolerant parser builds a tree out of anything, and the two readers + measured with the deepest pages either flatten the levels (Chromium) or + drop them (libxml2), so a tree would report something other than the file. + """ + nesting = settings.get("nesting", "0") + if not nesting.isdigit(): + fail(f"the html check was told nesting={nesting!r}, which is not a whole number") + want = int(nesting) + body = text + if shape == "document": + start = text.find("\n") + if start < 0: + fail("the page has no body to look for the levels in") + body = text[start + len("\n"):text.rfind("")] + if body.startswith("

    "): + # The label sits outside the levels, so a reader that drops the deep + # ones still shows which file it read. + body = body[body.index("

    \n") + len("

    \n"):] + opened = 0 + while body.startswith("
    \n", opened * 6): + opened += 1 + closed, end = 0, len(body) + while end >= 7 and body[end - 7:end] == "
    \n": + closed, end = closed + 1, end - 7 + if opened != want or closed != want: + fail(f"nesting={want} was ordered and the content opens {opened} levels and closes {closed}") + + chars = settings.get("characters", "ascii") + if chars == "international": + for piece in HTML_INTERNATIONAL: + if piece not in text: + fail(f"characters=international was ordered and the page does not carry {ascii(piece)}") + elif chars == "ascii": + stray = sorted({c for c in text if c != "\n" and not (" " <= c <= "~")}) + if stray: + fail(f"characters=ascii was ordered and the page holds {[hex(ord(c)) for c in stray[:6]]}") + else: + fail(f"the html check was told characters={chars!r}, which is not one this tool writes") + + elements = settings.get("elements", "basic") + names = {m.lower() for m in re.findall(r"<([a-zA-Z][a-zA-Z0-9-]*)", text)} + if elements == "every": + wanted = [e for e in HTML_ELEMENTS if e not in HTML_LEFT_OUT] + if shape == "fragment": + wanted = [e for e in wanted if e not in HTML_PAGE_ONLY] + missing = [e for e in wanted if e not in names] + if missing: + fail(f"elements=every was ordered and the page has no {missing[:8]}") + types = set(re.findall(r']*\btype="([a-z-]+)"', text)) + if any(t not in types for t in HTML_INPUT_TYPES): + fail(f"elements=every was ordered and these kinds of input are missing: " + f"{[t for t in HTML_INPUT_TYPES if t not in types]}") + elif elements == "basic": + if "main" in names or "form" in names: + fail("elements=basic was ordered and the page carries the catalogue of every element") + else: + fail(f"the html check was told elements={elements!r}, which is not one this tool writes") + if "script" in names: + fail("the page carries a script, which no setting of this format writes") + + def check_html(data, settings): """Balanced, the shape that was ordered, and with real blocks in it. @@ -670,6 +769,20 @@ def check_html(data, settings): "link", "meta", "param", "source", "track", "wbr"} BLOCKS = {"p", "h1", "h2", "h3", "ul", "ol", "table", "blockquote"} + settings = settings or {} + # Every setting is TOLD, for the reason the shape below gives: a checker that + # worked out what was ordered from the file would agree with whatever the + # file turned out to be. + if settings.get("bom", "false") not in ("true", "false"): + fail(f"the html check was told bom={settings.get('bom')!r}, which is not true or false") + mark = b"\xef\xbb\xbf" + if settings.get("bom") == "true": + if not data.startswith(mark): + fail("a byte order mark was ordered and the file does not open with one") + data = data[len(mark):] + elif data.startswith(mark): + fail("no byte order mark was ordered and the file opens with one") + try: text = data.decode("utf-8") except UnicodeDecodeError as exc: @@ -680,9 +793,10 @@ def check_html(data, settings): # agree with a fragment produced where a page was ordered, and with a page # produced where a fragment was. Those are the two defects this setting is # able to cause, so guessing here would leave nothing to catch them. - shape = (settings or {}).get("structure", "document") + shape = settings.get("structure", "document") if shape not in ("document", "fragment"): fail(f"the html check was told structure={shape!r}, which is not one this tool writes") + html_settings(text, shape, settings) opens = text.lower().startswith("") closes = text.rstrip().endswith("") @@ -699,12 +813,17 @@ def check_html(data, settings): fail("a fragment was ordered and the file opens with a doctype, so it is a whole page") if closes: fail("a fragment was ordered and the file ends with a closing html tag") - for tag in (", which belongs to a whole page") + # By the whole name: ", which the + # catalogue of every element carries in a fragment as well. + for name in ("html", "head", "body"): + if re.search(r"<" + name + r"[\s>]", text, re.IGNORECASE): + fail(f"a fragment was ordered and the file holds <{name}>, which belongs to a whole page") entity = re.compile(r"&(?:[a-zA-Z][a-zA-Z0-9]{1,31}|#[0-9]+|#x[0-9a-fA-F]+);") - tag = re.compile(r"<(/?)([a-zA-Z][a-zA-Z0-9]*)([^>]*)>") + # A hyphen belongs to a name since the catalogue carries a custom element, + # and a name cut at it would still balance - opened and closed - so + # the cut would never show. + tag = re.compile(r"<(/?)([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>") stack, blocks, i = [], 0, 0 for m in tag.finditer(text): diff --git a/web/public/ar/formats/index.html b/web/public/ar/formats/index.html index 85983bd5..e69a0b8a 100644 --- a/web/public/ar/formats/index.html +++ b/web/public/ar/formats/index.html @@ -656,6 +656,26 @@

    الإعدادات التي تقبلها كل صيغة

    structure document, fragment + + + nesting + 0 - 100000 + + + + characters + ascii, international + + + + elements + basic, every + + + + bom + صحيح أو خاطئ + ico width diff --git a/web/public/cs/formaty/index.html b/web/public/cs/formaty/index.html index d497c5a1..2e21f0b0 100644 --- a/web/public/cs/formaty/index.html +++ b/web/public/cs/formaty/index.html @@ -657,6 +657,26 @@

    Nastavení, která každý formát přijímá

    structure document, fragment + + + nesting + 0 - 100000 + + + + characters + ascii, international + + + + elements + basic, every + + + + bom + pravda nebo nepravda + ico width diff --git a/web/public/de/formate/index.html b/web/public/de/formate/index.html index ccfbe371..0f941f39 100644 --- a/web/public/de/formate/index.html +++ b/web/public/de/formate/index.html @@ -659,6 +659,26 @@

    Einstellungen, die jedes Format kennt

    structure document, fragment + + + nesting + 0 - 100000 + + + + characters + ascii, international + + + + elements + basic, every + + + + bom + wahr oder falsch + ico width diff --git a/web/public/es/formatos/index.html b/web/public/es/formatos/index.html index c7dc9661..308389e0 100644 --- a/web/public/es/formatos/index.html +++ b/web/public/es/formatos/index.html @@ -660,6 +660,26 @@

    Ajustes que admite cada formato

    structure document, fragment + + + nesting + 0 - 100000 + + + + characters + ascii, international + + + + elements + basic, every + + + + bom + verdadero o falso + ico width diff --git a/web/public/formats/index.html b/web/public/formats/index.html index e229be23..b3deab86 100644 --- a/web/public/formats/index.html +++ b/web/public/formats/index.html @@ -658,6 +658,26 @@

    Settings each format accepts

    structure document, fragment + + + nesting + 0 - 100000 + + + + characters + ascii, international + + + + elements + basic, every + + + + bom + true or false + ico width diff --git a/web/public/fr/formats/index.html b/web/public/fr/formats/index.html index b7b8ffc4..c927fb0d 100644 --- a/web/public/fr/formats/index.html +++ b/web/public/fr/formats/index.html @@ -660,6 +660,26 @@

    Réglages que chaque format accepte

    structure document, fragment + + + nesting + 0 - 100000 + + + + characters + ascii, international + + + + elements + basic, every + + + + bom + vrai ou faux + ico width diff --git a/web/public/hi/formats/index.html b/web/public/hi/formats/index.html index 2f33b0dd..cb6a8500 100644 --- a/web/public/hi/formats/index.html +++ b/web/public/hi/formats/index.html @@ -658,6 +658,26 @@

    हर फ़ॉर्मैट की स्वीकार की जा structure document, fragment + + + nesting + 0 - 100000 + + + + characters + ascii, international + + + + elements + basic, every + + + + bom + सही या गलत + ico width diff --git a/web/public/id/format/index.html b/web/public/id/format/index.html index fc0ef7fa..21f6d1a0 100644 --- a/web/public/id/format/index.html +++ b/web/public/id/format/index.html @@ -658,6 +658,26 @@

    Pengaturan yang diterima setiap format

    structure document, fragment + + + nesting + 0 - 100000 + + + + characters + ascii, international + + + + elements + basic, every + + + + bom + benar atau salah + ico width diff --git a/web/public/it/formati/index.html b/web/public/it/formati/index.html index eddcef48..29107970 100644 --- a/web/public/it/formati/index.html +++ b/web/public/it/formati/index.html @@ -660,6 +660,26 @@

    Impostazioni che ogni formato accetta

    structure document, fragment + + + nesting + 0 - 100000 + + + + characters + ascii, international + + + + elements + basic, every + + + + bom + vero o falso + ico width diff --git a/web/public/ja/formats/index.html b/web/public/ja/formats/index.html index f5b45ef6..26233d85 100644 --- a/web/public/ja/formats/index.html +++ b/web/public/ja/formats/index.html @@ -650,6 +650,26 @@

    形式ごとに受け付ける設定

    structure document, fragment + + + nesting + 0 - 100000 + + + + characters + ascii, international + + + + elements + basic, every + + + + bom + 真または偽 + ico width diff --git a/web/public/ko/formats/index.html b/web/public/ko/formats/index.html index 83dfb1f5..f64fb353 100644 --- a/web/public/ko/formats/index.html +++ b/web/public/ko/formats/index.html @@ -651,6 +651,26 @@

    각 형식이 받는 설정

    structure document, fragment + + + nesting + 0 - 100000 + + + + characters + ascii, international + + + + elements + basic, every + + + + bom + 참 또는 거짓 + ico width diff --git a/web/public/nl/formaten/index.html b/web/public/nl/formaten/index.html index 5c93128a..9dc97913 100644 --- a/web/public/nl/formaten/index.html +++ b/web/public/nl/formaten/index.html @@ -660,6 +660,26 @@

    Instellingen die elk formaat accepteert

    structure document, fragment + + + nesting + 0 - 100000 + + + + characters + ascii, international + + + + elements + basic, every + + + + bom + waar of onwaar + ico width diff --git a/web/public/pl/formaty/index.html b/web/public/pl/formaty/index.html index fde235cb..32104209 100644 --- a/web/public/pl/formaty/index.html +++ b/web/public/pl/formaty/index.html @@ -658,6 +658,26 @@

    Ustawienia, które przyjmuje każdy format

    structure document, fragment + + + nesting + 0 - 100000 + + + + characters + ascii, international + + + + elements + basic, every + + + + bom + prawda albo fałsz + ico width diff --git a/web/public/pt-br/formatos/index.html b/web/public/pt-br/formatos/index.html index 60f8cfbc..f3aca110 100644 --- a/web/public/pt-br/formatos/index.html +++ b/web/public/pt-br/formatos/index.html @@ -659,6 +659,26 @@

    Configurações que cada formato aceita

    structure document, fragment + + + nesting + 0 - 100000 + + + + characters + ascii, international + + + + elements + basic, every + + + + bom + verdadeiro ou falso + ico width diff --git a/web/public/ro/formate/index.html b/web/public/ro/formate/index.html index 26b815c0..54acf886 100644 --- a/web/public/ro/formate/index.html +++ b/web/public/ro/formate/index.html @@ -659,6 +659,26 @@

    Setările pe care le acceptă fiecare format

    structure document, fragment + + + nesting + 0 - 100000 + + + + characters + ascii, international + + + + elements + basic, every + + + + bom + adevărat sau fals + ico width diff --git a/web/public/ru/formats/index.html b/web/public/ru/formats/index.html index 5c99b80e..5c5cc463 100644 --- a/web/public/ru/formats/index.html +++ b/web/public/ru/formats/index.html @@ -658,6 +658,26 @@

    Настройки, которые принимает каждый форм structure document, fragment + + + nesting + 0 - 100000 + + + + characters + ascii, international + + + + elements + basic, every + + + + bom + истина или ложь + ico width diff --git a/web/public/th/formats/index.html b/web/public/th/formats/index.html index 1445a968..69ae4f1a 100644 --- a/web/public/th/formats/index.html +++ b/web/public/th/formats/index.html @@ -656,6 +656,26 @@

    การตั้งค่าที่แต่ละรูปแบบร structure document, fragment + + + nesting + 0 - 100000 + + + + characters + ascii, international + + + + elements + basic, every + + + + bom + จริงหรือเท็จ + ico width diff --git a/web/public/tr/bicimler/index.html b/web/public/tr/bicimler/index.html index 1fcc0d4a..643f8346 100644 --- a/web/public/tr/bicimler/index.html +++ b/web/public/tr/bicimler/index.html @@ -658,6 +658,26 @@

    Her biçimin kabul ettiği ayarlar

    structure document, fragment + + + nesting + 0 - 100000 + + + + characters + ascii, international + + + + elements + basic, every + + + + bom + doğru veya yanlış + ico width diff --git a/web/public/uk/formats/index.html b/web/public/uk/formats/index.html index e7e7e342..4ece25dc 100644 --- a/web/public/uk/formats/index.html +++ b/web/public/uk/formats/index.html @@ -658,6 +658,26 @@

    Налаштування, які приймає кожен формат

    structure document, fragment + + + nesting + 0 - 100000 + + + + characters + ascii, international + + + + elements + basic, every + + + + bom + істина або хибність + ico width diff --git a/web/public/vi/dinh-dang/index.html b/web/public/vi/dinh-dang/index.html index fa6acf5a..cd67bece 100644 --- a/web/public/vi/dinh-dang/index.html +++ b/web/public/vi/dinh-dang/index.html @@ -656,6 +656,26 @@

    Các thiết lập mỗi định dạng nhận

    structure document, fragment + + + nesting + 0 - 100000 + + + + characters + ascii, international + + + + elements + basic, every + + + + bom + đúng hoặc sai + ico width diff --git a/web/public/zh-hans/formats/index.html b/web/public/zh-hans/formats/index.html index 070ebb0c..7672855e 100644 --- a/web/public/zh-hans/formats/index.html +++ b/web/public/zh-hans/formats/index.html @@ -650,6 +650,26 @@

    每种格式接受的设置

    structure document, fragment + + + nesting + 0 - 100000 + + + + characters + ascii, international + + + + elements + basic, every + + + + bom + 真或假 + ico width diff --git a/web/public/zh-hant/formats/index.html b/web/public/zh-hant/formats/index.html index 8c260672..d05bc8ab 100644 --- a/web/public/zh-hant/formats/index.html +++ b/web/public/zh-hant/formats/index.html @@ -650,6 +650,26 @@

    每種格式接受的設定

    structure document, fragment + + + nesting + 0 - 100000 + + + + characters + ascii, international + + + + elements + basic, every + + + + bom + 真或假 + ico width From 46be08fbb76ad9cb6ab7f098118310c99af7ad9e Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 14 Oct 2026 10:52:28 +0200 Subject: [PATCH 2/2] html: the window offers the four new settings, and the strict check reads nesting safely The parity guard classifies html.bom, characters, elements and nesting as reachable from the window. The structural check accepts only ASCII digits up to 100000 for nesting before converting it, and asks for the page skeleton with one literal pattern. Co-Authored-By: Claude Opus 5.5 --- internal/guard/parity_test.go | 4 ++++ internal/oracle/strict.py | 13 ++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/internal/guard/parity_test.go b/internal/guard/parity_test.go index 4c80112d..30a8d456 100644 --- a/internal/guard/parity_test.go +++ b/internal/guard/parity_test.go @@ -194,6 +194,10 @@ var reachableFromTheWindow = []string{ "property:gif.frames", "property:gif.height", "property:gif.width", + "property:html.bom", + "property:html.characters", + "property:html.elements", + "property:html.nesting", "property:html.structure", "property:ico.embed", "property:ico.height", diff --git a/internal/oracle/strict.py b/internal/oracle/strict.py index bed404a8..a44f67d7 100644 --- a/internal/oracle/strict.py +++ b/internal/oracle/strict.py @@ -697,9 +697,12 @@ def html_settings(text, shape, settings): measured with the deepest pages either flatten the levels (Chromium) or drop them (libxml2), so a tree would report something other than the file. """ + # ASCII digits and the declared range, checked before int(): str.isdigit + # also says yes to "²", which int() then refuses with a traceback instead + # of a verdict (review of #177). nesting = settings.get("nesting", "0") - if not nesting.isdigit(): - fail(f"the html check was told nesting={nesting!r}, which is not a whole number") + if not re.fullmatch(r"[0-9]{1,6}", nesting) or int(nesting) > 100000: + fail(f"the html check was told nesting={nesting!r}, which is not a whole number from 0 to 100000") want = int(nesting) body = text if shape == "document": @@ -815,9 +818,9 @@ def check_html(data, settings): fail("a fragment was ordered and the file ends with a closing html tag") # By the whole name: ", which the # catalogue of every element carries in a fragment as well. - for name in ("html", "head", "body"): - if re.search(r"<" + name + r"[\s>]", text, re.IGNORECASE): - fail(f"a fragment was ordered and the file holds <{name}>, which belongs to a whole page") + skeleton = re.search(r"<(html|head|body)[\s>]", text, re.IGNORECASE) + if skeleton: + fail(f"a fragment was ordered and the file holds <{skeleton.group(1).lower()}>, which belongs to a whole page") entity = re.compile(r"&(?:[a-zA-Z][a-zA-Z0-9]{1,31}|#[0-9]+|#x[0-9a-fA-F]+);") # A hyphen belongs to a name since the catalogue carries a custom element,