diff --git a/CHANGELOG.md b/CHANGELOG.md
index c39cc220..6f814876 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -157,6 +157,17 @@ because it turns other people's test suites red.
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.
+- **HTML: pictures inside the page.** `inline`, `inline_format` and
+ `inline_size` - the same settings, with the same meaning, as the pictures
+ of an `eml` message - write real pictures of any picture format into the
+ page as `data:` URLs, each on a line of its own above the blocks, so
+ opening the page fetches nothing. Each picture is the very file its format
+ writes on its own. The Nu HTML Checker finds nothing wrong with a page of
+ any of the nine formats, Chromium shows every format but `tiff`, and
+ DOMPurify keeps them all. lxml, and BeautifulSoup on top of it, leave out
+ a picture past about 7.1 MB unless told to read huge trees - on Linux lxml
+ took over 20 seconds over such a page, and with a 15 MB picture it lost the
+ rest of the page as well. Files made without pictures keep their bytes.
- **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
@@ -266,6 +277,18 @@ because it turns other people's test suites red.
### Fixed
+- **Ctrl+C waited for a file that holds thousands of files to be planned.**
+ A zip, a tar.gz, a mail message or a page with ten thousand small pictures
+ inside took over a minute to plan, and a run stopped in that minute went
+ on until the end of it - in CI long enough for the grace period to run
+ out. Planning now stops between one file inside and the next, and says
+ it stopped rather than that a file could not be made.
+
+- **A stopped run went on writing a WebP picture to its last row.** A
+ 600 MB picture took five more seconds to stop. It now stops within a few
+ rows, as the other picture formats do. Files that are not stopped keep
+ their bytes.
+
- **Choosing another tool on the Tools tab left the boxes in the wrong
place.** The section kept the height of the tool chosen before, so a
setting could stand under it with its list hidden, or an empty band was
diff --git a/README.md b/README.md
index 1632f949..2acef44b 100644
--- a/README.md
+++ b/README.md
@@ -621,7 +621,7 @@ recipe. `tfg formats ` prints the allowed range or list for each:
| `geojson` | `geometry`, `formatting`, `precision`, `altitude`, `vertices`, `holes`, `winding`, `antimeridian`, `unlocated`, `ids`, `bbox` |
| `eml` | `body`, `text_encoding`, `charset`, `headers`, `recipients`, `line_endings`, `attachments`, `attachment_format`, `attachment_size`, `filename_style`, `message_depth`, `inline`, `inline_format`, `inline_size` |
| `svg` | `width`, `height` |
-| `html` | `structure`, `nesting`, `characters`, `elements`, `bom` |
+| `html` | `structure`, `nesting`, `characters`, `elements`, `bom`, `inline`, `inline_format`, `inline_size` |
| `yaml`, `toml` | none in this build - the document is a fixed shape, and a size is the only thing to ask for |
```
diff --git a/internal/engine/crash.go b/internal/engine/crash.go
index 9037fe89..68f1e17d 100644
--- a/internal/engine/crash.go
+++ b/internal/engine/crash.go
@@ -80,7 +80,10 @@ const (
// container planning its children calls them from inside its own Plan, so a
// child crashing is caught here too - and named as the container, because the
// container is what this was asked to plan.
-func planWithoutCrashing(desc format.Descriptor, r format.Request) (p format.Plan, err error) {
+//
+// The context reaches a container's own planning (format.PlanWith), so a stop
+// lands between two of the files inside it rather than after the last.
+func planWithoutCrashing(ctx context.Context, desc format.Descriptor, r format.Request) (p format.Plan, err error) {
defer func() {
v := recover()
if v == nil {
@@ -88,7 +91,7 @@ func planWithoutCrashing(desc format.Descriptor, r format.Request) (p format.Pla
}
err = &GeneratorCrashError{Format: desc.ID, While: crashPlanning, Value: v}
}()
- return desc.Generator.Plan(r)
+ return format.PlanWith(ctx, desc.Generator, r)
}
// writeWithoutCrashing is the same around the write, so a crash costs one file
diff --git a/internal/engine/drawsizes.go b/internal/engine/drawsizes.go
index 96327656..1274ad07 100644
--- a/internal/engine/drawsizes.go
+++ b/internal/engine/drawsizes.go
@@ -1,6 +1,7 @@
package engine
import (
+ "context"
"errors"
"github.com/donislawdev/TestingFilesGenerator/internal/core"
@@ -44,7 +45,7 @@ func firstWritable(desc format.Descriptor, r format.Request, from, limit int64)
var first error
for at := from; at <= limit; {
r.Bytes = at
- _, err := planWithoutCrashing(desc, r)
+ _, err := planWithoutCrashing(context.Background(), desc, r)
if err == nil {
return at, nil
}
@@ -128,7 +129,7 @@ func drawSizes(t *Target, desc format.Descriptor, targetSeed uint64) error {
// Asked again at the low end so the format words its own refusal, with
// the number the person actually wrote.
first.Bytes = t.Range.Min
- if _, err := planWithoutCrashing(desc, first); err != nil {
+ if _, err := planWithoutCrashing(context.Background(), desc, first); err != nil {
return err
}
}
diff --git a/internal/engine/plantarget.go b/internal/engine/plantarget.go
index 70b664cc..0ae79a1d 100644
--- a/internal/engine/plantarget.go
+++ b/internal/engine/plantarget.go
@@ -35,7 +35,7 @@ func (pl *planning) files(ctx context.Context, t *Target, desc format.Descriptor
for idx, size := range t.Sizes {
fileSeed := core.FileSeed(targetSeed, idx)
- p, err := planWithoutCrashing(desc, format.Request{
+ p, err := planWithoutCrashing(ctx, desc, format.Request{
Bytes: size,
SizeFromContents: t.SizeFromContents,
Contains: t.Contains,
diff --git a/internal/format/eml/attach.go b/internal/format/eml/attach.go
index e561fa0f..036e0c27 100644
--- a/internal/format/eml/attach.go
+++ b/internal/format/eml/attach.go
@@ -1,6 +1,7 @@
package eml
import (
+ "context"
"encoding/base64"
"fmt"
"io"
@@ -64,12 +65,12 @@ func (c *child) raw() bool { return c.desc.MediaType == messageType }
// per format across the whole message - the archives' rules, for their
// reasons (zip/children.go). The order is the order the seeds were given in
// before pictures and depth existed, so neither moves an attached file.
-func planChildren(r format.Request, groups []format.Content, s settings) (children, pics []child, err error) {
+func planChildren(ctx context.Context, r format.Request, groups []format.Content, s settings) (children, pics []child, err error) {
total := 0
for _, g := range groups {
total += g.Count
}
- p := planner{r: r, s: s, out: make([]child, 0, total+1), numbered: map[string]int{}}
+ p := planner{ctx: ctx, r: r, s: s, out: make([]child, 0, total+1), numbered: map[string]int{}}
for _, g := range groups {
desc, err := format.Get(g.Format)
if err != nil {
@@ -89,6 +90,7 @@ func planChildren(r format.Request, groups []format.Content, s settings) (childr
// planner is planChildren's place in the message as it goes: how many files
// came before, how many of each format, and how many bytes of them so far.
type planner struct {
+ ctx context.Context
r format.Request
s settings
out []child
@@ -119,9 +121,9 @@ func (p *planner) attachment(c child) {
// plan plans one file of a size with the seed of its place, and counts what it
// adds to the message.
func (p *planner) plan(desc format.Descriptor, size int64, place int) (child, error) {
- cp, err := desc.Generator.Plan(p.request(desc, size, place))
+ cp, err := format.PlanWith(p.ctx, desc.Generator, p.request(desc, size, place))
if err != nil {
- return child{}, core.Refuse(core.Says("eml.TheAttachedFileCannotBe", "eml: the attached %s file cannot be made: %w", core.A("Format", desc.ID), core.A("Err", err)))
+ return child{}, format.StopOr(p.ctx, core.Refuse(core.Says("eml.TheAttachedFileCannotBe", "eml: the attached %s file cannot be made: %w", core.A("Format", desc.ID), core.A("Err", err))))
}
if cp.Bytes > maxMessage {
return child{}, tooLarge(cp.Bytes)
@@ -161,7 +163,7 @@ func (p *planner) forward() error {
r := p.request(desc, 0, len(p.out))
r.Properties[MessageDepth] = strconv.Itoa(p.s.depth - 1)
r.SizeFromContents, r.Contains = true, []format.Content{}
- cp, err := desc.Generator.Plan(r)
+ cp, err := format.PlanWith(p.ctx, desc.Generator, r)
if err != nil {
return err
}
diff --git a/internal/format/eml/eml.go b/internal/format/eml/eml.go
index 1de0aaf7..d748b66e 100644
--- a/internal/format/eml/eml.go
+++ b/internal/format/eml/eml.go
@@ -18,6 +18,7 @@ import (
"github.com/donislawdev/TestingFilesGenerator/internal/core"
"github.com/donislawdev/TestingFilesGenerator/internal/format"
"github.com/donislawdev/TestingFilesGenerator/internal/format/archive"
+ "github.com/donislawdev/TestingFilesGenerator/internal/format/picture"
)
const generatorVersion = "1"
@@ -64,7 +65,13 @@ func minimumBytes() int64 {
return newMessage(s, 0, nil, nil).size()
}
-func (generator) Plan(r format.Request) (format.Plan, error) {
+func (g generator) Plan(r format.Request) (format.Plan, error) {
+ return g.PlanContext(context.Background(), r)
+}
+
+// PlanContext is Plan, stoppable between one file the message carries and the
+// next.
+func (generator) PlanContext(ctx context.Context, r format.Request) (format.Plan, error) {
s, err := parse(r.Properties)
if err != nil {
return format.Plan{}, err
@@ -73,7 +80,7 @@ func (generator) Plan(r format.Request) (format.Plan, error) {
if err != nil {
return format.Plan{}, err
}
- children, pics, err := planChildren(r, groups, s)
+ children, pics, err := planChildren(ctx, r, groups, s)
if err != nil {
return format.Plan{}, err
}
@@ -242,19 +249,15 @@ func forwarded(m *message) int {
return 0
}
-// describePictures adds the pictures to what reaches the manifest - always
-// their settings, so a test reads the same keys whether there are any or not.
+// describePictures adds the pictures to what reaches the manifest, in the
+// words html uses for its own.
func describePictures(props map[string]any, m *message) {
var raw, encoded int64
for _, c := range m.pictures {
raw += c.plan.Bytes
encoded += c.encoded
}
- props[Inline] = len(m.pictures)
- props[InlineFormat] = m.s.pictures.format
- props[InlineSize] = m.s.pictures.size
- props["inline_bytes"] = raw
- props["inline_encoded_bytes"] = encoded
+ picture.Describe(props, m.s.pictures, len(m.pictures), raw, encoded)
}
func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error {
diff --git a/internal/format/eml/inline.go b/internal/format/eml/inline.go
index e92e9bf7..472c0cf6 100644
--- a/internal/format/eml/inline.go
+++ b/internal/format/eml/inline.go
@@ -2,12 +2,11 @@ package eml
import (
"fmt"
- "strconv"
"strings"
"github.com/donislawdev/TestingFilesGenerator/internal/core"
"github.com/donislawdev/TestingFilesGenerator/internal/format"
- "github.com/donislawdev/TestingFilesGenerator/internal/format/archive"
+ "github.com/donislawdev/TestingFilesGenerator/internal/format/picture"
)
// Pictures in the text: real files of a picture format, carried beside the
@@ -21,97 +20,40 @@ import (
// lists them when the HTML is alone, MimeKit never does. A system that limits
// or counts attachments meets the same file three ways.
-// Setting names, public under rule 10.
+// Setting names, public under rule 10. The declaration and its reading are
+// shared with html, which carries pictures inside the page (package picture).
const (
- Inline = "inline"
- InlineFormat = "inline_format"
- InlineSize = "inline_size"
+ Inline = picture.Count
+ InlineFormat = picture.Format
+ InlineSize = picture.Size
)
// picturesGroup is the block the picture settings sit in, on both surfaces.
const picturesGroup = "Pictures in the text"
-// defaultPictureFormat is what a picture is when nothing says - the format a
-// mail program puts a pasted picture in.
-const defaultPictureFormat = "png"
-
-// pictures is what the inline settings ask for.
-type pictures struct {
- count int
- format string
- size int64
-}
-
// pictureProperties are the settings of the pictures the text shows.
func pictureProperties() []format.Property {
- return []format.Property{
- {
- Name: Inline, Kind: format.PropertyInt,
- Min: 0, Max: archive.MaxMembers,
- Default: "0",
- Detail: "How many pictures the text shows. With html and alternative each one sits beside the HTML and the HTML points at it through cid:. " +
- "With plain there is no HTML to point from, so they travel after the text as pictures to show inline. " +
- "mailparser lists them among the attachments, and so does Python's email package when the HTML is alone. MimeKit never does.",
- Group: picturesGroup,
- },
- {
- Name: InlineFormat, Kind: format.PropertyText,
- Shape: "the id of a picture format, as tfg formats lists them",
- Default: defaultPictureFormat,
- Detail: "The format of the pictures. Any format whose files are pictures, such as png, jpg or gif.",
- Group: picturesGroup,
- },
- {
- Name: InlineSize, Kind: format.PropertySize,
- Default: archive.DefaultSizeText,
- Detail: "How big each picture is. Like an attached file it travels in base64, about a third larger.",
- Group: picturesGroup,
- },
- }
-}
-
-// readPictures is the three settings as numbers. Whether the format is a
-// picture is asked in Plan, where the registry is complete - this also runs
-// while the formats are still registering, to work out the minimum.
-func readPictures(values map[string]string) (pictures, error) {
- n, err := strconv.Atoi(values[Inline])
- if err != nil {
- return pictures{}, core.Defect(err)
- }
- size, err := core.ParseSize(values[InlineSize])
- if err != nil {
- return pictures{}, core.Defect(err)
- }
- return pictures{count: n, format: values[InlineFormat], size: size}, nil
+ return picture.Properties(picturesGroup, picture.Details{
+ Count: "How many pictures the text shows. With html and alternative each one sits beside the HTML and the HTML points at it through cid:. " +
+ "With plain there is no HTML to point from, so they travel after the text as pictures to show inline. " +
+ "mailparser lists them among the attachments, and so does Python's email package when the HTML is alone. MimeKit never does.",
+ Format: "The format of the pictures. Any format whose files are pictures, such as png, jpg or gif.",
+ Size: "How big each picture is. Like an attached file it travels in base64, about a third larger.",
+ })
}
// pictureFormat is the descriptor of the format the pictures are, or the
// refusal when it is not a picture.
func pictureFormat(id string) (format.Descriptor, error) {
- desc, err := format.Get(id)
- if err != nil {
- return format.Descriptor{}, err
- }
- if strings.HasPrefix(desc.MediaType, "image/") {
- return desc, nil
+ desc, ok, err := picture.Of(id)
+ if err != nil || ok {
+ return desc, err
}
return format.Descriptor{}, &format.PropertyValueError{
Format: "eml", Key: InlineFormat, Value: id,
Reason: core.Says("eml.APictureInTheTextIs", "a picture in the text has to be a file a mail program can show as a picture"),
- Remedy: core.Says("eml.UseOneOfThePictureFormats", "Use one of: %s.", core.A("Formats", strings.Join(pictureFormats(), ", "))),
- }
-}
-
-// pictureFormats are the formats of this build whose files are pictures, from
-// the media type each declares rather than from a list kept here.
-func pictureFormats() []string {
- var out []string
- for _, d := range format.All() {
- if strings.HasPrefix(d.MediaType, "image/") {
- out = append(out, d.ID)
- }
+ Remedy: core.Says("eml.UseOneOfThePictureFormats", "Use one of: %s.", core.A("Formats", strings.Join(picture.Formats(), ", "))),
}
- return out
}
// addPictures plans the pictures, after the attached files, with seeds that
@@ -121,14 +63,14 @@ func pictureFormats() []string {
// value the setting cannot take is a mistake either way, and accepting it in
// silence would hand back a message the recipe did not describe.
func (p *planner) addPictures(s settings) ([]child, error) {
- desc, err := pictureFormat(s.pictures.format)
- if err != nil || s.pictures.count == 0 {
+ desc, err := pictureFormat(s.pictures.Format)
+ if err != nil || s.pictures.Count == 0 {
return nil, err
}
- out := make([]child, 0, s.pictures.count)
+ out := make([]child, 0, s.pictures.Count)
first := len(p.out)
- for i := 0; i < s.pictures.count; i++ {
- c, err := p.plan(desc, s.pictures.size, first+i)
+ for i := 0; i < s.pictures.Count; i++ {
+ c, err := p.plan(desc, s.pictures.Size, first+i)
if err != nil {
return nil, err
}
diff --git a/internal/format/eml/settings.go b/internal/format/eml/settings.go
index 9c6d53bb..501e5e24 100644
--- a/internal/format/eml/settings.go
+++ b/internal/format/eml/settings.go
@@ -6,6 +6,7 @@ import (
"github.com/donislawdev/TestingFilesGenerator/internal/core"
"github.com/donislawdev/TestingFilesGenerator/internal/format"
"github.com/donislawdev/TestingFilesGenerator/internal/format/archive"
+ "github.com/donislawdev/TestingFilesGenerator/internal/format/picture"
)
// Setting names. Public names under rule 10, so each is spelled once.
@@ -110,7 +111,7 @@ type settings struct {
// messages deep the message goes, itself counted.
recipients int
depth int
- pictures pictures
+ pictures picture.Settings
}
// international says whether the text carries the line in Polish (and in
@@ -174,7 +175,7 @@ func parse(props map[string]string) (settings, error) {
if s.depth, err = strconv.Atoi(values[MessageDepth]); err != nil {
return settings{}, core.Defect(err)
}
- s.pictures, err = readPictures(values)
+ s.pictures, err = picture.Parse(values)
return s, err
}
diff --git a/internal/format/format.go b/internal/format/format.go
index dfccf7fe..00fb379f 100644
--- a/internal/format/format.go
+++ b/internal/format/format.go
@@ -733,6 +733,45 @@ type Generator interface {
Write(ctx context.Context, w io.Writer, p Plan) error
}
+// ContextPlanner is a Generator whose Plan can be stopped part of the way
+// through, which is what a format that holds other files needs.
+//
+// A container plans every file inside it from inside its own Plan, and the
+// engine asks for a stop only between one file and the next. Ten thousand
+// small pngs in a page took 72 s to plan on 2026-10-14, the same in a mail
+// message, a zip and a tar.gz, and a stopped run waited for all of it - under
+// SIGTERM in CI long enough for SIGKILL to arrive (review of #178,
+// docs/HTML-3-2026-10-14.md section 11).
+type ContextPlanner interface {
+ PlanContext(ctx context.Context, r Request) (Plan, error)
+}
+
+// PlanWith plans r with g, stoppable as far as g is: asked once before it
+// starts, and between the files inside it when g is a ContextPlanner.
+//
+// A container plans its files through this too, so a container inside a
+// container stops as soon as the outer one does.
+func PlanWith(ctx context.Context, g Generator, r Request) (Plan, error) {
+ if err := ctx.Err(); err != nil {
+ return Plan{}, err
+ }
+ if c, ok := g.(ContextPlanner); ok {
+ return c.PlanContext(ctx, r)
+ }
+ return g.Plan(r)
+}
+
+// StopOr is what a container returns when a file inside it could not be
+// planned: the stop itself when the run was stopped, so it reaches the command
+// line as a stop with its own exit code rather than as a file that cannot be
+// made, and err otherwise.
+func StopOr(ctx context.Context, err error) error {
+ if stop := ctx.Err(); stop != nil {
+ return stop
+ }
+ return err
+}
+
// BelowMinimumError is refusing a size a format cannot deliver.
//
// It carries four things on purpose: which format, what its minimum is, why
diff --git a/internal/format/htmlfile/catalogue.go b/internal/format/htmlfile/catalogue.go
index d08d7d7e..67950605 100644
--- a/internal/format/htmlfile/catalogue.go
+++ b/internal/format/htmlfile/catalogue.go
@@ -22,11 +22,12 @@ package htmlfile
// 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"
+ // smallPicture 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.
+ smallPicture = "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
@@ -97,7 +98,7 @@ const (
-
+
@@ -113,15 +114,15 @@ const (
Media
-A picture with a map.
+A picture with a map.
-
+
-
+
-
-
diff --git a/internal/format/htmlfile/html.go b/internal/format/htmlfile/html.go
index 0928f860..c6efe840 100644
--- a/internal/format/htmlfile/html.go
+++ b/internal/format/htmlfile/html.go
@@ -56,9 +56,9 @@ func init() {
// than a comment.
Label: format.LabelVisible,
Oracle: "python-html",
- // 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
+ // 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,
@@ -73,15 +73,27 @@ type memo struct {
labelLine string // the visible heading, empty when absent
seed uint64
settings settings
+ pictures []pictureLine
+ // pictureBytes is what the lines of the pictures take, together.
+ pictureBytes int64
}
-func (generator) Plan(r format.Request) (format.Plan, error) {
+func (g generator) Plan(r format.Request) (format.Plan, error) {
+ return g.PlanContext(context.Background(), r)
+}
+
+// PlanContext is Plan, stoppable between one picture and the next.
+func (generator) PlanContext(ctx context.Context, r format.Request) (format.Plan, error) {
s, err := settingsOf(r.Properties)
if err != nil {
return format.Plan{}, err
}
- min := minimumFor(s)
+ pictures, pictureBytes, err := planPictures(ctx, r, s.pictures)
+ if err != nil {
+ return format.Plan{}, err
+ }
+ min := minimumFor(s) + pictureBytes
if r.Bytes < min {
return format.Plan{}, belowMinimum(s, r.Bytes, min)
}
@@ -107,6 +119,7 @@ func (generator) Plan(r format.Request) (format.Plan, error) {
for k, v := range s.manifest() {
p.Properties[k] = v
}
+ describePictures(p.Properties, s.pictures, pictures)
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
@@ -118,7 +131,7 @@ func (generator) Plan(r format.Request) (format.Plan, error) {
})
}
- m := memo{seed: r.Seed, head: s.prologue(), settings: s}
+ m := memo{seed: r.Seed, head: s.prologue(), settings: s, pictures: pictures, pictureBytes: pictureBytes}
if r.Label {
var note *format.Note
m.head, m.labelLine, note = labelledHead(s, r, min)
@@ -136,6 +149,10 @@ func (generator) Plan(r format.Request) (format.Plan, error) {
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.pictures.Count > 0:
+ // The pictures are what a tester raises the size for, so they are what
+ // the reason names, whatever else was asked.
+ reason = core.Says("html.MinimumWithPictures", "the file holds whole blocks and every picture in base64, which is about a third larger than the picture itself, and together they need that much")
case s.adds():
reason = core.Says("html.MinimumWithSettings", "the file holds whole blocks and what %s, %s, %s and %s add to them, and together they need that much",
core.A("Nesting", core.LabelTerm(settingNesting)), core.A("Characters", core.LabelTerm(settingCharacters)),
@@ -195,9 +212,12 @@ func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error {
if err := core.WriteAll(w, []byte(head)); err != nil {
return err
}
+ if err := writePictures(ctx, w, m.pictures); err != nil {
+ return err
+ }
rng := core.NewRand(m.seed)
- return core.FillRecords(ctx, w, rng, p.Bytes-int64(len(head)), m.settings.blocks())
+ return core.FillRecords(ctx, w, rng, p.Bytes-int64(len(head))-m.pictureBytes, m.settings.blocks())
}
// minimumBytes is the smallest whole page, which is what the registry declares.
diff --git a/internal/format/htmlfile/pictures.go b/internal/format/htmlfile/pictures.go
new file mode 100644
index 00000000..80d67482
--- /dev/null
+++ b/internal/format/htmlfile/pictures.go
@@ -0,0 +1,172 @@
+package htmlfile
+
+import (
+ "context"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "strings"
+
+ "github.com/donislawdev/TestingFilesGenerator/internal/core"
+ "github.com/donislawdev/TestingFilesGenerator/internal/format"
+ "github.com/donislawdev/TestingFilesGenerator/internal/format/picture"
+)
+
+// Pictures inside the page: real files of a picture format, each in a data:
+// URL (RFC 2397) on a line of its own, the way a page saved as one file
+// carries them. The settings are the ones a mail message has, with the same
+// names and the same meaning (package picture).
+//
+// Measured on 2026-10-14 on pages from this program (docs/HTML-3-2026-10-14.md
+// section 9), and this is what the details below say: Chromium shows every
+// picture format but tiff, the Nu HTML Checker calls a line break inside src an
+// error - so the base64 is one line - and lxml keeps no attribute past ten
+// million characters unless told to read huge trees.
+
+// picturesGroup is the block the picture settings sit in, on both surfaces.
+const picturesGroup = "Pictures in the page"
+
+// maxPage is the largest page this format plans with pictures in it. Far above
+// anything a disk holds, and far enough below the int64 every size is counted
+// in that adding the pictures up cannot wrap round - eml's maxMessage, for its
+// reason.
+const maxPage = int64(1) << 60
+
+// pictureClose ends the line a picture sits on.
+const pictureClose = `">
` + "\n"
+
+// pictureProperties are the settings of the pictures the page shows.
+func pictureProperties() []format.Property {
+ return picture.Properties(picturesGroup, picture.Details{
+ Count: "How many pictures the page shows, each one a real file of its format written into the page as a data: URL, so opening the page fetches nothing. " +
+ "They sit above the blocks, one to a line, and each says which it is in its alt text.",
+ Format: "The format of the pictures. Any format whose files are pictures, such as png, jpg or gif. Chromium shows every one of them but tiff.",
+ Size: "How big each picture is. Inside the page it is base64 on one line, about a third larger. " +
+ "lxml, and BeautifulSoup on top of it, leave out a picture past about 7.1 MB unless told to read huge trees. " +
+ "On Linux lxml took over 20 seconds over such a page, and with a 15 MB picture it lost the rest of the page as well.",
+ })
+}
+
+// pictureLine is one picture, planned: the file, and the line it sits on up
+// to where the base64 starts.
+type pictureLine struct {
+ desc format.Descriptor
+ plan format.Plan
+ open string
+}
+
+// bytes is what the line takes in the page.
+func (p pictureLine) bytes() int64 {
+ return int64(len(p.open)) + base64Length(p.plan.Bytes) + int64(len(pictureClose))
+}
+
+// planPictures plans the pictures, each with the seed of its place (RC4), so
+// asking for one more changes none of the others. The format is asked whether
+// it is a picture even when none are asked for: a value the setting cannot
+// take is a mistake either way, and eml refuses it the same way.
+func planPictures(ctx context.Context, r format.Request, s picture.Settings) ([]pictureLine, int64, error) {
+ desc, ok, err := picture.Of(s.Format)
+ if err != nil {
+ return nil, 0, err
+ }
+ if !ok {
+ return nil, 0, &format.PropertyValueError{
+ Format: "html", Key: picture.Format, Value: s.Format,
+ Reason: core.Says("html.APictureInThePageIs", "a picture in the page has to be a file of a picture format"),
+ Remedy: core.Says("html.UseOneOfThePictureFormats", "Use one of: %s.", core.A("Formats", strings.Join(picture.Formats(), ", "))),
+ }
+ }
+ out := make([]pictureLine, 0, s.Count)
+ var sum int64
+ for i := 0; i < s.Count; i++ {
+ cp, err := format.PlanWith(ctx, desc.Generator, format.Request{Bytes: s.Size, Seed: core.FileSeed(r.Seed, i), Label: r.Label})
+ if err != nil {
+ return nil, 0, format.StopOr(ctx, core.Refuse(core.Says("html.ThePictureCannotBe", "html: the %s picture cannot be made: %w",
+ core.A("Format", desc.ID), core.A("Err", err))))
+ }
+ if cp.Bytes > maxPage {
+ return nil, 0, pageTooLarge(cp.Bytes)
+ }
+ line := pictureLine{desc: desc, plan: cp, open: pictureOpen(desc, i+1)}
+ if sum += line.bytes(); sum > maxPage {
+ return nil, 0, pageTooLarge(sum)
+ }
+ out = append(out, line)
+ }
+ return out, sum, nil
+}
+
+// pictureOpen is a picture's line up to its base64. The alt text is the name
+// the picture has in a mail message, inline_0001.png, so a tester reading
+// either finds the same picture by the same name.
+func pictureOpen(desc format.Descriptor, n int) string {
+ name := fmt.Sprintf("inline_%04d%s", n, desc.Extension)
+ return `
0 {
+ c.left--
+ return nil
+ }
+ select {
+ case <-c.done:
+ default:
+ close(c.done)
+ }
+ return context.Canceled
+}
+
+func (c *countdown) Done() <-chan struct{} { return c.done }
+
+// containerRequests are the four formats that plan files inside them, each
+// asked for forty, which a live context plans in well under a second.
+func containerRequests() map[string]format.Request {
+ forty := []format.Content{{Format: "txt", Count: 40, Bytes: 1024}}
+ pictures := map[string]string{"inline": "40", "inline_size": "1kb"}
+ return map[string]format.Request{
+ "html": {Bytes: 1 << 20, Seed: 7741, Label: true, Properties: pictures},
+ "eml": {Bytes: 1 << 20, Seed: 7741, Label: true, Properties: pictures},
+ "zip": {Bytes: 1 << 20, Seed: 7741, Label: true, Contains: forty},
+ "targz": {Bytes: 1 << 20, Seed: 7741, Label: true, Contains: forty},
+ }
+}
+
+// TestAContainerStopsBetweenTheFilesItPlans asks each container twice. With a
+// live context it has to plan - otherwise the stop below could be a refusal of
+// something else and pass for the thing guarded. With a context that stops
+// after three questions it has to end with the stop itself, and NOT as a
+// refusal: a window marks the box a refusal is about, and a stop is the answer
+// to what was asked for rather than a fault in any box.
+func TestAContainerStopsBetweenTheFilesItPlans(t *testing.T) {
+ for id, r := range containerRequests() {
+ d, err := format.Get(id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := d.Generator.(format.ContextPlanner); !ok {
+ t.Errorf("%s plans files inside it and cannot be stopped while it does", id)
+ continue
+ }
+ if _, err := format.PlanWith(context.Background(), d.Generator, r); err != nil {
+ t.Errorf("%s: forty files inside are not planned with a live context, so the stop is not asked: %v", id, err)
+ continue
+ }
+ _, err = format.PlanWith(newCountdown(3), d.Generator, r)
+ if !errors.Is(err, context.Canceled) {
+ t.Errorf("%s: stopped after three questions and planning ended with %v, not the stop", id, err)
+ }
+ var refusal *core.Refusal
+ if errors.As(err, &refusal) {
+ t.Errorf("%s: a stop came back as a refusal, %q, so a window would mark a box for it", id, err)
+ }
+ }
+}
+
+// TestTheEngineHandsTheStopToAContainerItIsPlanning is the other half: the
+// containers listen, and the engine has to be the one talking to them. A page
+// of ten thousand small pictures takes over a minute to plan, so a run stopped
+// a moment in has to end long before that, with the stop.
+func TestTheEngineHandsTheStopToAContainerItIsPlanning(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go func() {
+ time.Sleep(200 * time.Millisecond)
+ cancel()
+ }()
+ start := time.Now()
+ _, err := engine.PlanContext(ctx, []engine.Target{{
+ ID: "g", Format: "html", Sizes: engine.Uniform(1, 1<<30), Label: true,
+ Properties: map[string]string{"inline": "10000", "inline_size": "8kb"},
+ }}, engine.Options{OutDir: t.TempDir(), Seed: 7741})
+ took := time.Since(start)
+ if !errors.Is(err, context.Canceled) {
+ t.Errorf("planning a page of ten thousand pictures, stopped after 200 ms, ended with %v after %v, not the stop", err, took)
+ }
+ if took > 20*time.Second {
+ t.Errorf("planning stopped after 200 ms took %v to notice", took)
+ }
+}
diff --git a/internal/guard/generatorbytes_test.go b/internal/guard/generatorbytes_test.go
index 7dea328f..59fcb455 100644
--- a/internal/guard/generatorbytes_test.go
+++ b/internal/guard/generatorbytes_test.go
@@ -458,6 +458,14 @@ func pageGoldenCases() map[string]engine.Target {
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"}},
+ // Pictures inside the page: the default format, and an odd size in a
+ // fragment inside levels, with a format whose own bytes are text. Each
+ // picture is the file its format writes alone, so these also pin the
+ // seeds the pictures are given (RC4).
+ "html_32kib_pictures": {ID: "g", Format: "html", Sizes: engine.Uniform(1, 32768), Label: true,
+ Properties: map[string]string{"inline": "3", "inline_size": "2kb"}},
+ "html_odd_fragment_pictures_svg": {ID: "g", Format: "html", Sizes: engine.Uniform(1, 16385), Label: true,
+ Properties: map[string]string{"structure": "fragment", "nesting": "2", "inline": "2", "inline_format": "svg", "inline_size": "1kb"}},
}
}
diff --git a/internal/guard/generatorcrash_test.go b/internal/guard/generatorcrash_test.go
index 9e5b2fe7..71e7f096 100644
--- a/internal/guard/generatorcrash_test.go
+++ b/internal/guard/generatorcrash_test.go
@@ -117,7 +117,11 @@ func TestAGeneratorThatCrashesWhileWritingCostsOneFile(t *testing.T) {
// borrowed encoder.
func TestEveryCallIntoAGeneratorGoesThroughTheCrashWrapper(t *testing.T) {
const wrapper = "crash.go"
- calls := []string{"Generator.Plan(", "Generator.Write("}
+ // format.PlanWith is how the wrapper plans since 2026-10-14, so a
+ // container can be stopped between the files inside it - and it calls the
+ // generator, so anywhere else in the engine it is a way round the wrapper.
+ calls := []string{"format.PlanWith(", "Generator.Write("}
+ direct := "Generator.Plan("
dir := filepath.Join(repoRoot(t), "internal", "engine")
entries, err := os.ReadDir(dir)
@@ -134,7 +138,7 @@ func TestEveryCallIntoAGeneratorGoesThroughTheCrashWrapper(t *testing.T) {
t.Fatalf("reading %s: %v", e.Name(), err)
}
read++
- for _, call := range calls {
+ for _, call := range append(calls, direct) {
if strings.Contains(string(body), call) {
t.Errorf("%s calls %s itself, so a panic in there ends the process",
e.Name(), strings.TrimSuffix(call, "("))
diff --git a/internal/guard/htmlpictures_test.go b/internal/guard/htmlpictures_test.go
new file mode 100644
index 00000000..7e06ae28
--- /dev/null
+++ b/internal/guard/htmlpictures_test.go
@@ -0,0 +1,186 @@
+package guard
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+
+ "github.com/donislawdev/TestingFilesGenerator/internal/core"
+ "github.com/donislawdev/TestingFilesGenerator/internal/format"
+ pic "github.com/donislawdev/TestingFilesGenerator/internal/format/picture"
+ "github.com/donislawdev/TestingFilesGenerator/internal/oracle"
+)
+
+// The pictures a page carries inside itself, from 2026-10-14
+// (docs/HTML-3-2026-10-14.md). Read from the bytes, for the reason
+// htmlsettings_test.go gives.
+
+// htmlPictureLine is one picture's line as a page carries it, the base64 on the
+// one line the Nu HTML Checker allows (a line break inside src is an error
+// there, measured on 2026-10-14).
+var htmlPictureLine = regexp.MustCompile(`
\n`)
+
+// TestAPictureInAPageIsTheFileItsFormatWritesAlone is the claim of inline.
+//
+// For every picture format of this build, from the registry rather than from a
+// list here: the page carries as many pictures as were ordered, each named and
+// typed as its format, each on one line, and each the very bytes the format
+// writes on its own with the seed of its place - so a picture is a real file a
+// tester can cut out of the page, not something that only looks like one.
+func TestAPictureInAPageIsTheFileItsFormatWritesAlone(t *testing.T) {
+ formats := pic.Formats()
+ if len(formats) == 0 {
+ t.Fatal("this build has no picture format, so there is nothing a page could carry")
+ }
+ for _, id := range formats {
+ desc, err := format.Get(id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ props := map[string]string{"inline": "2", "inline_format": id, "inline_size": "2kb"}
+ body := writeEncoded(t, "html", 65536, props)
+ found := htmlPictureLine.FindAllSubmatch(body, -1)
+ if len(found) != 2 {
+ t.Errorf("%s: inline=2 was ordered and the page carries %d pictures on lines of their own", id, len(found))
+ continue
+ }
+ if n := bytes.Count(body, []byte(`= s.after {
+ s.cancel()
+ }
+ return len(p), nil
+}
+
+func TestEveryPictureFormatStopsSoonAfterARunIsStopped(t *testing.T) {
+ const size, stopAt = int64(64 << 20), int64(1 << 20)
+ formats := pic.Formats()
+ if len(formats) == 0 {
+ t.Fatal("this build has no picture format, so nothing below is asked")
+ }
+ for _, id := range formats {
+ d, err := format.Get(id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ p, err := d.Generator.Plan(format.Request{Bytes: size, Seed: 7741, Label: true})
+ if err != nil {
+ t.Errorf("%s: %d B cannot be planned, so its stop is not asked: %v", id, size, err)
+ continue
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ w := &stoppingWriter{cancel: cancel, after: stopAt}
+ err = d.Generator.Write(ctx, w, p)
+ cancel()
+ if !errors.Is(err, context.Canceled) {
+ t.Errorf("%s: stopped after 1 MiB of %d B and the write ended with %v, not the stop", id, size, err)
+ }
+ if w.n > size/4 {
+ t.Errorf("%s: stopped after 1 MiB and wrote %d B of %d B before it noticed", id, w.n, size)
+ }
+ }
+}
diff --git a/internal/guard/testdata/generator-golden.json b/internal/guard/testdata/generator-golden.json
index 91e48be4..2a4cb8e6 100644
--- a/internal/guard/testdata/generator-golden.json
+++ b/internal/guard/testdata/generator-golden.json
@@ -61,6 +61,10 @@
"bytes": 16384,
"sha256": "68b1ebd280622c1aa3489480267742e4c3612f4e7c76ac9bac67b78b2948943d"
},
+ "html_32kib_pictures": {
+ "bytes": 32768,
+ "sha256": "e96131166dc62c8f00bfef915533400d88dfb4891795b4e2673048bd4aae6a50"
+ },
"html_64kib_everything": {
"bytes": 65536,
"sha256": "716512ac2db8bdadafed56cd909a60d7c4de4c2d31ed0516eee9a9f5b5a6df5c"
@@ -77,6 +81,10 @@
"bytes": 8193,
"sha256": "9dde31f7afb6857f78283ccc516ea3f609d31d60f08fa19f335f869aa0f494f7"
},
+ "html_odd_fragment_pictures_svg": {
+ "bytes": 16385,
+ "sha256": "c654ffeef0bd8f07115f647620c47ca12849d7a26614bf389682fe1e7e3ad5fd"
+ },
"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 128817ba..e93a4aea 100644
--- a/internal/gui/text/locale/registry/en.json
+++ b/internal/gui/text/locale/registry/en.json
@@ -1309,6 +1309,21 @@
"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.inline": {
+ "description": "The sentence under the inline setting of the html format, after what it takes.",
+ "hash": "sha256-3815222270e4",
+ "other": "How many pictures the page shows, each one a real file of its format written into the page as a data: URL, so opening the page fetches nothing. They sit above the blocks, one to a line, and each says which it is in its alt text."
+ },
+ "Detail.format/html.inline_format": {
+ "description": "The sentence under the inline_format setting of the html format, after what it takes.",
+ "hash": "sha256-0c0288233958",
+ "other": "The format of the pictures. Any format whose files are pictures, such as png, jpg or gif. Chromium shows every one of them but tiff."
+ },
+ "Detail.format/html.inline_size": {
+ "description": "The sentence under the inline_size setting of the html format, after what it takes.",
+ "hash": "sha256-2433610ca9da",
+ "other": "How big each picture is. Inside the page it is base64 on one line, about a third larger. lxml, and BeautifulSoup on top of it, leave out a picture past about 7.1 MB unless told to read huge trees. On Linux lxml took over 20 seconds over such a page, and with a 15 MB picture it lost the rest of the page as well."
+ },
"Detail.format/html.nesting": {
"description": "The sentence under the nesting setting of the html format, after what it takes.",
"hash": "sha256-cac70df12b4b",
@@ -1969,6 +1984,11 @@
"hash": "sha256-c741ca3d02e5",
"other": "Pictures in the text"
},
+ "Group.format/html.Pictures in the page": {
+ "description": "The heading over a block of settings of the html format.",
+ "hash": "sha256-af608b8ed33a",
+ "other": "Pictures in the page"
+ },
"Group.format/pdf.Document properties": {
"description": "The heading over a block of settings of the pdf format.",
"hash": "sha256-7115a778ab47",
diff --git a/internal/gui/text/locale/registry/pl.json b/internal/gui/text/locale/registry/pl.json
index 5d571807..ad248496 100644
--- a/internal/gui/text/locale/registry/pl.json
+++ b/internal/gui/text/locale/registry/pl.json
@@ -261,6 +261,9 @@
"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.inline": { "hash": "sha256-3815222270e4", "other": "Ile obrazków pokazuje strona. Każdy jest prawdziwym plikiem swojego formatu wpisanym w stronę jako adres data:, więc otwarcie strony niczego nie pobiera. Leżą nad blokami, po jednym w linii, a każdy mówi w tekście alt, który to obrazek." },
+ "Detail.format/html.inline_format": { "hash": "sha256-0c0288233958", "other": "Format obrazków. Dowolny format, którego pliki są obrazkami, na przykład png, jpg albo gif. Chromium pokazuje każdy z nich poza tiff." },
+ "Detail.format/html.inline_size": { "hash": "sha256-2433610ca9da", "other": "Jak duży jest każdy obrazek. W stronie jest zapisany w base64 w jednej linii, mniej więcej o jedną trzecią większy. lxml, a z nim BeautifulSoup, pomija obrazek większy niż około 7,1 MB, chyba że kazano mu czytać ogromne drzewa. Na Linuksie lxml czytał taką stronę ponad 20 sekund, a przy obrazku 15 MB zgubił też resztę strony." },
"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." },
@@ -393,6 +396,7 @@
"Format.zip": { "hash": "sha256-eaca4b306928", "other": "ZIP" },
"Group.format/eml.Attached files": { "hash": "sha256-ecc5a28d1f5e", "other": "Załączone pliki" },
"Group.format/eml.Pictures in the text": { "hash": "sha256-c741ca3d02e5", "other": "Obrazki w treści" },
+ "Group.format/html.Pictures in the page": { "hash": "sha256-af608b8ed33a", "other": "Obrazki w stronie" },
"Group.format/pdf.Document properties": { "hash": "sha256-7115a778ab47", "other": "Właściwości dokumentu" },
"Input.checksum-check.checksum_file": { "hash": "sha256-90d7baf0d228", "other": "Plik sum kontrolnych. Ścieżki w nim są czytane z folderu, w którym leży, i nic nie jest zapisywane." },
"Input.checksum-write.folder": { "hash": "sha256-3040f185111b", "other": "Folder do spisania. Każdy plik w nim jest czytany, a plik sum kontrolnych jest zapisywany do niego." },
diff --git a/internal/gui/text/locale/said/en.json b/internal/gui/text/locale/said/en.json
index 07439189..0868c5c2 100644
--- a/internal/gui/text/locale/said/en.json
+++ b/internal/gui/text/locale/said/en.json
@@ -1755,6 +1755,21 @@
"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.APageThisLargeCannotBe": {
+ "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.",
+ "hash": "sha256-fcc13b4d27c7",
+ "other": "a page this large cannot be counted to the byte in this build"
+ },
+ "html.APictureInThePageIs": {
+ "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.",
+ "hash": "sha256-485930df62e0",
+ "other": "a picture in the page has to be a file of a picture format"
+ },
+ "html.AskForSmallerPictures": {
+ "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.",
+ "hash": "sha256-acf878874a21",
+ "other": "Ask for fewer or smaller pictures."
+ },
"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",
@@ -1790,11 +1805,26 @@
"hash": "sha256-3745440d090a",
"other": "a page holds a head, a body and whole blocks, and one of each needs that much"
},
+ "html.MinimumWithPictures": {
+ "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.",
+ "hash": "sha256-af57b5e328fe",
+ "other": "the file holds whole blocks and every picture in base64, which is about a third larger than the picture itself, and together they need that much"
+ },
"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"
},
+ "html.ThePictureCannotBe": {
+ "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 {{.Format}}, {{.Err}}, written the way the program writes them - keep each spelled exactly that way.",
+ "hash": "sha256-2c602605a691",
+ "other": "html: the {{.Format}} picture cannot be made: {{.Err}}"
+ },
+ "html.UseOneOfThePictureFormats": {
+ "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 {{.Formats}}, written the way the program writes them - keep each spelled exactly that way.",
+ "hash": "sha256-a6669f6abe6b",
+ "other": "Use one of: {{.Formats}}."
+ },
"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 618f538a..50957294 100644
--- a/internal/gui/text/locale/said/pl.json
+++ b/internal/gui/text/locale/said/pl.json
@@ -349,6 +349,9 @@
"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.APageThisLargeCannotBe": { "hash": "sha256-fcc13b4d27c7", "other": "tak dużej strony ta wersja nie potrafi policzyć co do bajta" },
+ "html.APictureInThePageIs": { "hash": "sha256-485930df62e0", "other": "obrazek w stronie musi być plikiem formatu obrazka" },
+ "html.AskForSmallerPictures": { "hash": "sha256-acf878874a21", "other": "Poproś o mniej albo mniejsze obrazki." },
"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}}" },
@@ -356,7 +359,10 @@
"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.MinimumWithPictures": { "hash": "sha256-af57b5e328fe", "other": "plik zawiera całe bloki i każdy obrazek w base64, który jest mniej więcej o jedną trzecią większy od samego obrazka, a razem potrzebują 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" },
+ "html.ThePictureCannotBe": { "hash": "sha256-2c602605a691", "other": "html: obrazek {{.Format}} nie może powstać: {{.Err}}" },
+ "html.UseOneOfThePictureFormats": { "hash": "sha256-a6669f6abe6b", "other": "Użyj jednego z: {{.Formats}}." },
"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 a44f67d7..7d6a7b7f 100644
--- a/internal/oracle/strict.py
+++ b/internal/oracle/strict.py
@@ -755,6 +755,90 @@ def html_settings(text, shape, settings):
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")
+ html_pictures(text, settings)
+
+
+# The picture formats a page may carry and what each is called inside it - its
+# extension in the alt text and its media type in the data: URL - written here
+# from the formats' own declarations on 2026-10-14 rather than imported.
+HTML_PICTURE_TYPES = {
+ "avif": (".avif", "image/avif"), "bmp": (".bmp", "image/bmp"), "gif": (".gif", "image/gif"),
+ "ico": (".ico", "image/vnd.microsoft.icon"), "jpg": (".jpg", "image/jpeg"), "jxl": (".jxl", "image/jxl"),
+ "png": (".png", "image/png"), "svg": (".svg", "image/svg+xml"), "tiff": (".tiff", "image/tiff"),
+ "webp": (".webp", "image/webp"),
+}
+# One picture's line, as the generator writes it: the base64 on one line,
+# because the Nu HTML Checker calls a line break inside src an error
+# (docs/HTML-3-2026-10-14.md section 1).
+HTML_PICTURE = re.compile(r'
\n')
+
+
+def html_size(word):
+ """A size as a recipe writes it, in units of 1024 or as a byte count."""
+ match = re.fullmatch(r"([0-9]{1,15})(b|kb|mb|gb)?", word.lower())
+ if not match:
+ fail(f"the html check was told inline_size={word!r}, which is not a size this tool takes")
+ return int(match.group(1)) * {None: 1, "b": 1, "kb": 1024, "mb": 1024 ** 2, "gb": 1024 ** 3}[match.group(2)]
+
+
+def html_pictures(text, settings):
+ """The pictures inline ordered: as many as were asked, numbered in order,
+ each of the format and size asked and each a file its own check accepts.
+
+ TOLD, never counted from the page and believed, for the reason the shape
+ check gives. The catalogue of every element carries pictures of its own,
+ and they are told apart by their alt text.
+ """
+ count = settings.get("inline", "0")
+ if not re.fullmatch(r"[0-9]{1,5}", count) or int(count) > 10000:
+ fail(f"the html check was told inline={count!r}, which is not a whole number from 0 to 10000")
+ kind = settings.get("inline_format", "png")
+ if kind not in HTML_PICTURE_TYPES:
+ fail(f"the html check was told inline_format={kind!r}, which is not a picture format")
+ size = html_size(settings.get("inline_size", "8kb"))
+ ext, media = HTML_PICTURE_TYPES[kind]
+ found = HTML_PICTURE.findall(text)
+ if len(found) != int(count):
+ fail(f"inline={count} was ordered and the page carries {len(found)} pictures")
+ if "base64," in text and text.count('الإعدادات التي تقبلها كل صيغة