From a97f292d9313fd058d8e6ab3e4167c31d5676209 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Thu, 15 Oct 2026 20:31:24 +0200 Subject: [PATCH 1/2] rtf: pictures in \pict and links in HYPERLINK fields inline, inline_format and inline_size put real png or jpg files into the document, with the names, ranges and defaults a page and a mail message have, each in a paragraph that starts with its name. picture_data writes their bytes in hex lines of 128 or binary after \bin. links adds up to a million HYPERLINK fields, 17 kinds in turn, inside a bookmark the first kind points at. Default settings write the bytes they always wrote. The structural checker cuts every \pict group out of the bytes before reading the rest as text, and is told the five new settings. The pandoc guard reads the pictures back in hex and holds the binary canary: pandoc breaks every png written binary. The stop guard of containers finds every format carrying pictures from the registry instead of a list by hand. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 21 ++ README.md | 2 +- internal/format/rtf/links.go | 141 ++++++++ internal/format/rtf/pictures.go | 314 ++++++++++++++++++ internal/format/rtf/rtf.go | 57 +++- internal/format/rtf/settings.go | 57 +++- internal/guard/containerstop_test.go | 34 +- internal/guard/generatorbytes_test.go | 11 + internal/guard/layers_test.go | 2 +- internal/guard/notelemetry_test.go | 11 + internal/guard/parity_test.go | 5 + internal/guard/rtf_test.go | 69 +++- internal/guard/rtfpictures_test.go | 247 ++++++++++++++ internal/guard/testdata/generator-golden.json | 20 ++ internal/gui/text/locale/registry/en.json | 45 +++ internal/gui/text/locale/registry/pl.json | 9 + internal/gui/text/locale/said/en.json | 41 ++- internal/gui/text/locale/said/pl.json | 9 +- internal/oracle/rtfscripts.go | 38 +++ internal/oracle/strict.py | 173 +++++++++- web/public/ar/formats/index.html | 25 ++ web/public/cs/formaty/index.html | 25 ++ web/public/de/formate/index.html | 25 ++ web/public/es/formatos/index.html | 25 ++ web/public/formats/index.html | 25 ++ web/public/fr/formats/index.html | 25 ++ web/public/hi/formats/index.html | 25 ++ web/public/id/format/index.html | 25 ++ web/public/it/formati/index.html | 25 ++ web/public/ja/formats/index.html | 25 ++ web/public/ko/formats/index.html | 25 ++ web/public/nl/formaten/index.html | 25 ++ web/public/pl/formaty/index.html | 25 ++ web/public/pt-br/formatos/index.html | 25 ++ web/public/ro/formate/index.html | 25 ++ web/public/ru/formats/index.html | 25 ++ web/public/th/formats/index.html | 25 ++ web/public/tr/bicimler/index.html | 25 ++ web/public/uk/formats/index.html | 25 ++ web/public/vi/dinh-dang/index.html | 25 ++ web/public/zh-hans/formats/index.html | 25 ++ web/public/zh-hant/formats/index.html | 25 ++ 42 files changed, 1820 insertions(+), 36 deletions(-) create mode 100644 internal/format/rtf/links.go create mode 100644 internal/format/rtf/pictures.go create mode 100644 internal/guard/rtfpictures_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a82955b..77e45a4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,27 @@ because it turns other people's test suites red. code page the header declares. A large document is a long one to read: at 100 MiB pandoc took 95 s and 9.6 GB of memory, and LibreOffice had not finished after 20 minutes. +- **Documents: pictures and links in `rtf`.** `inline`, `inline_format` and + `inline_size` put real `png` or `jpg` files into the document, the settings + a page and a mail message have, each in a paragraph that starts with its + name, such as `inline_0001.png`, the one description all six readers + measured return. Any other format is refused, since RTF names only those + two. `picture_data` says how their bytes are written: `hex`, twice the size + of the picture, the way LibreOffice writes them, or `binary`, byte for + byte, so the document is no longer text and a program that counts braces + or reads it as text loses its place. LibreOffice, pandoc and Tika give back + each picture in hex as the very file it is, and RichEdit shows the same + picture and writes it out again smaller. pandoc changes the line ends + inside a binary picture, so no `png` comes out whole, and it can lose the + text after the picture or refuse the whole document. `links` adds up to 1 000 000 links, 17 + kinds in turn, each saying which kind it is - a bookmark in the document, + relative, absolute, without a scheme, `mailto:`, `tel:`, `data:`, a new + window, a screen tip and more - and every reader makes something different + of them: LibreOffice turns the one without a scheme into a `file://` + address, pandoc 3.1 and 3.7 drop every link with its text, and RichEdit + returns each address in front of its text. The manifest records the + pictures in the keys a page and a message use, `picture_data` and + `links`. - **Films: the `webm` format.** A WebM video with an AV1 picture and no sound, at the exact size you ask for, like every other format. Its length is a setting of its own, independent of the size. The picture moves: every diff --git a/README.md b/README.md index cede26e1..64950636 100644 --- a/README.md +++ b/README.md @@ -627,7 +627,7 @@ recipe. `tfg formats ` prints the allowed range or list for each: | `docx` | `paragraphs` | | `xlsx` | `rows`, `columns` | | `pptx` | `slides` | -| `rtf` | `characters`, `text_encoding`, `elements`, `nesting` | +| `rtf` | `characters`, `text_encoding`, `elements`, `nesting`, `links`, `inline`, `inline_format`, `inline_size`, `picture_data` | | `csv` | `delimiter`, `line_ending`, `header`, `quote_style`, `columns` | | `log` | `entry_format`, `timestamps`, `rate`, `methods`, `status_mix`, `level_mix`, `ip_version`, `line_ending` | | `txt`, `md`, `xml` | `encoding`, `bom` | diff --git a/internal/format/rtf/links.go b/internal/format/rtf/links.go new file mode 100644 index 00000000..4a90604c --- /dev/null +++ b/internal/format/rtf/links.go @@ -0,0 +1,141 @@ +package rtf + +import ( + "context" + "io" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + +// The links a document carries when links is above 0: HYPERLINK fields, one to +// a paragraph, after what the settings open the document with and before the +// pictures and the blocks - the order a page has them in. +// +// Seventeen kinds, in turn, each saying in its text what kind it is, so what a +// program pulls out of the document reads without this file beside it. Read on +// 2026-10-15 by six readers, and each made something different of them +// (docs/RTF-2-2026-10-15.md section 1.2), which is what the detail below says. +// A switch of the field is written with its backslash escaped, the way RTF +// writes a backslash. A page has a download link and a nofollow link, and a +// field has nothing that says either, so they are not here. A path on the disk +// or a share and a field that fetches something belong to the security set +// (decision D3). +// +// Every address is on a domain RFC 2606 keeps for examples, and the number is +// in the range NANPA keeps for fiction, so a link followed by mistake reaches +// nobody. All of them are ASCII, which keeps links and characters independent. +var linkKinds = []struct{ instruction, text string }{ + {`HYPERLINK \\l "tfg_links"`, "link to a bookmark in this document"}, + {`HYPERLINK "page-2.rtf"`, "relative link"}, + {`HYPERLINK "../folder/page-3.rtf"`, "link to the folder above"}, + {`HYPERLINK "/root/page-4.rtf"`, "link from the root"}, + {`HYPERLINK "https://example.com/path/page.html?a=1&b=2#part"`, "absolute link with a query and a fragment"}, + {`HYPERLINK "https://example.com/path/page.html" \\l "part"`, "absolute link with a location"}, + {`HYPERLINK "http://example.org/"`, "http link"}, + {`HYPERLINK "//example.net/scheme-relative"`, "link without a scheme"}, + {`HYPERLINK "https://example.com/caf%C3%A9?q=two%20words"`, "percent encoded link"}, + {`HYPERLINK "https://xn--bcher-kva.example/"`, "international domain link"}, + {`HYPERLINK "https://example.com:8443/port"`, "link with a port"}, + {`HYPERLINK "mailto:someone@example.com?subject=Hello%20there"`, "mail link"}, + {`HYPERLINK "tel:+1-555-0100"`, "phone link"}, + {`HYPERLINK "ftp://example.com/file.txt"`, "ftp link"}, + {`HYPERLINK "https://example.org/new" \\t "_blank"`, "link to a new window"}, + {`HYPERLINK "https://example.net/tip" \\o "This sentence is the screen tip of a link."`, "link with a screen tip"}, + {`HYPERLINK "data:text/plain,hello"`, "data link"}, +} + +// The pieces of a link's paragraph around its kind, and the bookmark the first +// kind points at, which spans the whole list the way the id of the list does +// in a page. +const ( + fieldOpen = `{\field{\*\fldinst ` + fieldResult = `}{\fldrslt ` + fieldClose = `}}` + bookmarkStart = `{\*\bkmkstart tfg_links}` + bookmarkEnd = `{\*\bkmkend tfg_links}` +) + +// linksDetail is what both surfaces say about the setting. +const linksDetail = "How many links the document carries, each a HYPERLINK field in a paragraph of its own, above the pictures and the blocks. " + + "They go through 17 kinds in turn and then repeat - a link to a bookmark in the document, relative, to the folder above, from the root, " + + "absolute with a query and a fragment, absolute with a location, http, without a scheme, percent encoded, an international domain, a port, " + + "mailto:, tel:, ftp:, a new window, a screen tip and data: - and the text of each says which it is. " + + "Every address is on a domain kept for examples. " + + "LibreOffice turns the link without a scheme into a file:// address and rewrites the relative ones from where it saves the document. " + + "pandoc 3.12 leaves the bookmark link without an address, pandoc 3.1 and 3.7 drop every link with its text, " + + "and RichEdit returns the address of each link in front of its text. " + + "LibreOffice took over 11 minutes over a million links. " + + "The catalogue of every part has one link of its own, which this does not count." + +// linksBytes is what the list takes, worked out without building it. +func linksBytes(n int) int64 { + if n == 0 { + return 0 + } + var cycle, part int64 + for i, k := range linkKinds { + size := int64(len(k.instruction) + len(k.text)) + cycle += size + if i < n%len(linkKinds) { + part += size + } + } + each := int64(len(fieldOpen) + len(fieldResult) + len(fieldClose) + len(endBlock)) + count := int64(n) + return int64(len(bookmarkStart)+len(bookmarkEnd)) + count*each + count/int64(len(linkKinds))*cycle + part +} + +// writeLinks writes the list a chunk at a time, so a million links never sit in +// memory and an interrupted run stops between two chunks. +func writeLinks(ctx context.Context, w io.Writer, n int) error { + out := chunks{ctx: ctx, w: w} + for i := 0; i < n && out.err == nil; i++ { + k := linkKinds[i%len(linkKinds)] + if i == 0 { + out.add(bookmarkStart) + } + out.add(fieldOpen, k.instruction, fieldResult, k.text, fieldClose) + if i == n-1 { + out.add(bookmarkEnd) + } + out.add(endBlock) + } + return out.close() +} + +// writeChunk is how much is gathered before it is written, and so how often a +// stop is noticed. +const writeChunk = 32 * 1024 + +// chunks gathers pieces and writes them a chunk at a time, after asking +// whether the run was stopped. +type chunks struct { + ctx context.Context + w io.Writer + buf []byte + err error +} + +func (c *chunks) add(pieces ...string) { + if c.err != nil { + return + } + for _, p := range pieces { + c.buf = append(c.buf, p...) + } + if len(c.buf) < writeChunk { + return + } + if c.err = c.ctx.Err(); c.err == nil { + c.err = core.WriteAll(c.w, c.buf) + } + c.buf = c.buf[:0] +} + +// close writes what is left, or says why nothing more was written. +func (c *chunks) close() error { + if c.err != nil { + return c.err + } + return core.WriteAll(c.w, c.buf) +} diff --git a/internal/format/rtf/pictures.go b/internal/format/rtf/pictures.go new file mode 100644 index 00000000..cf12d877 --- /dev/null +++ b/internal/format/rtf/pictures.go @@ -0,0 +1,314 @@ +package rtf + +import ( + "context" + "encoding/hex" + "fmt" + "io" + "strconv" + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/format/picture" +) + +// Pictures in the document: real files of a picture format, each in a \pict +// group in a paragraph of its own that starts with its name, after the links +// and before the blocks - where a page has them. +// +// Measured on 2026-10-15 by six readers on documents made by hand around files +// from this program (docs/RTF-2-2026-10-15.md section 1.1). In hex every one of +// them read every picture, LibreOffice, pandoc and Tika giving back the very +// bytes and RichEdit the same picture written again. Written binary, pandoc +// turns the line ends inside the picture into line feeds, so no PNG comes out +// whole - its signature holds one - and once the shift reaches the brace that +// closes the picture it refuses the document. The name in front is the one +// description all six returned: LibreOffice and pandoc kept nothing of the one +// a picture can carry inside its group. +// +// The settings are the ones a mail message and a page have, with the same +// names and the same meaning (package picture), and the group shape is the one +// LibreOffice writes: the picture's size in pixels and on the page, then the +// data, in hex lines of 128. + +// picturesGroup is the block the picture settings sit in, on both surfaces. +const picturesGroup = "Pictures in the document" + +// maxDocument is the largest document this format plans with pictures in it, +// for the reason a page has its own: 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. +const maxDocument = int64(1) << 60 + +// blips are the picture formats RTF has a name for, among those this program +// writes - the only ones a document can carry as the files they are. +var blips = map[string]string{"png": `\pngblip`, "jpg": `\jpegblip`} + +// blipFormats is the list a refusal names, in the order the detail says it. +const blipFormats = "png, jpg" + +const ( + // hexLine is how many hex digits stand on one line, as LibreOffice writes + // them. Even, so a byte never splits across two lines. + hexLine = 128 + // twipsPerPixel puts a picture on the page at 96 pixels to the inch, the + // size a screen shows it at. + twipsPerPixel = 15 + // columnTwips is the width of the text on the page RTF assumes when a + // document names none: 12 240 twips of paper less two margins of 1800 + // (RTF 1.6). No picture is set wider, so none stands out past the text. + columnTwips = 12240 - 2*1800 +) + +// pictureProperties are the settings of the pictures, and how their bytes are +// written. +func pictureProperties() []format.Property { + return append(picture.Properties(picturesGroup, picture.Details{ + Count: "How many pictures the document shows, each a real file of its format in a paragraph of its own that starts with its name, such as inline_0001.png. " + + "They sit above the blocks, at 96 pixels to the inch and never wider than 6 inches. " + + "LibreOffice, pandoc and Tika return each file as it was written. RichEdit shows the same picture and writes it out again smaller.", + Format: "The format of the pictures, png or jpg - the two picture formats RTF has a name for. Any other format is refused.", + Size: "How big each picture is. Written in hex it takes twice as many bytes in the document, with a line end every 128 characters. " + + "Written binary it takes its own size.", + }), format.Property{ + Name: PictureData, Kind: format.PropertyChoice, + Choices: []string{Hex, Binary}, + Default: Hex, + Group: picturesGroup, + Detail: "How the bytes of each picture are written, which changes nothing while inline is 0. " + + "hex writes them as text, twice as large, the way LibreOffice writes them. " + + "binary writes them as they are, so the document is no longer text, and a program that counts braces or reads it as text loses its place. " + + "pandoc changes the line ends inside a binary picture, so no png comes out whole, and it can lose the text after the picture or refuse the whole document. " + + "LibreOffice, Tika and RichEdit read both.", + }) +} + +// pictureLine is one picture, planned: the file, and its paragraph up to where +// the data starts and after it ends. +type pictureLine struct { + desc format.Descriptor + plan format.Plan + open, close string + binary bool +} + +// dataBytes is what the picture's data takes in the document. +func (p pictureLine) dataBytes() int64 { + if p.binary { + return p.plan.Bytes + } + return hexLength(p.plan.Bytes) +} + +// bytes is what the paragraph takes in the document. +func (p pictureLine) bytes() int64 { + return int64(len(p.open)) + p.dataBytes() + int64(len(p.close)) +} + +// hexLength is how many bytes n bytes take in hex lines of hexLine, each line +// after a line end of its own. +func hexLength(n int64) int64 { + digits := 2 * n + return digits + 2*((digits+hexLine-1)/hexLine) +} + +// 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 +// RTF can carry it even when none are asked for: a value the setting cannot +// take is a mistake either way, and a page and a message refuse it the same +// way. +func planPictures(ctx context.Context, r format.Request, s settings) ([]pictureLine, int64, error) { + desc, blip, err := pictureFormat(s.pictures.Format) + if err != nil { + return nil, 0, err + } + out := make([]pictureLine, 0, s.pictures.Count) + var sum int64 + for i := 0; i < s.pictures.Count; i++ { + cp, err := format.PlanWith(ctx, desc.Generator, format.Request{Bytes: s.pictures.Size, Seed: core.FileSeed(r.Seed, i), Label: r.Label}) + if err != nil { + return nil, 0, format.StopOr(ctx, core.Refuse(core.Says("rtf.ThePictureCannotBe", "rtf: the %s picture cannot be made: %w", + core.A("Format", desc.ID), core.A("Err", err)))) + } + if cp.Bytes > maxDocument { + return nil, 0, documentTooLarge(cp.Bytes) + } + open, err := pictureOpen(desc, blip, cp, i+1, s.binary) + if err != nil { + return nil, 0, err + } + line := pictureLine{desc: desc, plan: cp, open: open, close: "}" + endBlock, binary: s.binary} + if sum += line.bytes(); sum > maxDocument { + return nil, 0, documentTooLarge(sum) + } + out = append(out, line) + } + return out, sum, nil +} + +// pictureFormat is the descriptor of the pictures' format and the word RTF +// names it by, or the refusal of a format RTF cannot carry. +func pictureFormat(id string) (format.Descriptor, string, error) { + desc, _, err := picture.Of(id) + if err != nil { + return format.Descriptor{}, "", err + } + blip, ok := blips[desc.ID] + if !ok { + return format.Descriptor{}, "", &format.PropertyValueError{ + Format: "rtf", Key: picture.Format, Value: id, + Reason: core.Says("rtf.APictureInTheDocumentIs", "a picture in an RTF document has to be one of the two picture formats RTF has a name for"), + Remedy: core.Says("rtf.UseOneOfThePictureFormats", "Use one of: %s.", core.A("Formats", blipFormats)), + } + } + return desc, blip, nil +} + +// pictureOpen is a picture's paragraph up to its data: the name the picture +// has in a mail message and in a page, inline_0001.png, so a tester finds the +// same picture by the same name in all three, then the group with the size in +// pixels and on the page, and with binary the count of bytes that follow. +func pictureOpen(desc format.Descriptor, blip string, cp format.Plan, n int, binary bool) (string, error) { + w, h, err := pixels(desc, cp) + if err != nil { + return "", err + } + gw, gh := w*twipsPerPixel, h*twipsPerPixel + if gw > columnTwips { + gw, gh = columnTwips, h*columnTwips/w + } + var b strings.Builder + fmt.Fprintf(&b, "inline_%04d%s ", n, desc.Extension) + b.WriteString(`{\pict` + blip) + fmt.Fprintf(&b, `\picw%d\pich%d\picwgoal%d\pichgoal%d`, w, h, gw, gh) + if binary { + b.WriteString(`\bin` + strconv.FormatInt(cp.Bytes, 10) + " ") + } + return b.String(), nil +} + +// pixels are the picture's width and height, as its plan records them. A plan +// of png or jpg without them is this program's mistake: the group has to say +// how large the picture is. +func pixels(desc format.Descriptor, cp format.Plan) (int, int, error) { + w, wok := cp.Properties["width"].(int) + h, hok := cp.Properties["height"].(int) + if !wok || !hok || w <= 0 || h <= 0 { + return 0, 0, core.Defect(fmt.Errorf("rtf: the %s picture's plan gives no width and height: %v", desc.ID, cp.Properties)) + } + return w, h, nil +} + +// documentTooLarge refuses pictures that add up past maxDocument. +func documentTooLarge(asked int64) error { + return &format.AboveMaximumError{ + Format: "RTF", + Requested: asked, + Maximum: maxDocument, + Reason: core.Says("rtf.ADocumentThisLargeCannotBe", "a document this large cannot be counted to the byte in this build"), + Hint: core.Says("rtf.AskForSmallerPictures", "Ask for fewer or smaller pictures."), + } +} + +// describePictures adds the pictures to what reaches the manifest, in the +// words a mail message and a page use for theirs: how many bytes they are as +// files, and as they stand in the document. +func describePictures(props map[string]any, s picture.Settings, lines []pictureLine) { + var raw, encoded int64 + for _, p := range lines { + raw += p.plan.Bytes + encoded += p.dataBytes() + } + picture.Describe(props, s, len(lines), raw, encoded) +} + +// writePictures writes every picture's paragraph, each file made by its own +// generator as it is written, so a picture of a hundred megabytes never sits in +// memory. +func writePictures(ctx context.Context, w io.Writer, lines []pictureLine) error { + for _, p := range lines { + if err := core.WriteAll(w, []byte(p.open)); err != nil { + return err + } + if err := writeData(ctx, w, p); err != nil { + return err + } + if err := core.WriteAll(w, []byte(p.close)); err != nil { + return err + } + } + return nil +} + +// writeData writes one picture's bytes, as they are or in hex lines. The size +// of the whole document rests on each picture coming out as its plan says, so +// one that does not is a defect, not a document of the wrong size. +func writeData(ctx context.Context, w io.Writer, p pictureLine) error { + out := &dataWriter{w: w, binary: p.binary} + if err := p.desc.Generator.Write(ctx, out, p.plan); err != nil { + return err + } + if err := out.flush(); err != nil { + return err + } + if out.n != p.plan.Bytes { + return core.Defect(fmt.Errorf("rtf: the %s picture came out as %d B where its plan gives %d B", p.desc.ID, out.n, p.plan.Bytes)) + } + return nil +} + +// dataWriter writes a picture's bytes in hex lines of hexLine, each after a +// line end of its own, or as they are when binary - and counts the bytes it was +// given either way. +type dataWriter struct { + w io.Writer + binary bool + buf []byte + col int + n int64 +} + +func (h *dataWriter) Write(p []byte) (int, error) { + h.n += int64(len(p)) + if h.binary { + if err := core.WriteAll(h.w, p); err != nil { + return 0, err + } + return len(p), nil + } + for rest := p; len(rest) > 0; { + rest = h.appendLine(rest) + if err := h.flushFull(); err != nil { + return 0, err + } + } + return len(p), nil +} + +// appendLine adds as much of p in hex as the line holds, opening the line with +// its line end, and returns the rest. +func (h *dataWriter) appendLine(p []byte) []byte { + if h.col == 0 { + h.buf = append(h.buf, '\r', '\n') + } + k := min(len(p), (hexLine-h.col)/2) + h.buf = hex.AppendEncode(h.buf, p[:k]) + h.col = (h.col + 2*k) % hexLine + return p[k:] +} + +// flushFull writes the hex gathered once it is a chunk. +func (h *dataWriter) flushFull() error { + if len(h.buf) < writeChunk { + return nil + } + return h.flush() +} + +// flush writes the hex gathered so far. +func (h *dataWriter) flush() error { + err := core.WriteAll(h.w, h.buf) + h.buf = h.buf[:0] + return err +} diff --git a/internal/format/rtf/rtf.go b/internal/format/rtf/rtf.go index 7150515c..b43d42c0 100644 --- a/internal/format/rtf/rtf.go +++ b/internal/format/rtf/rtf.go @@ -60,14 +60,26 @@ type memo struct { seed uint64 // labelLine is the paragraph that carries the label, empty when absent. labelLine string + pictures []pictureLine + // pictureBytes is what the paragraphs 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 := s.layout().fixedBytes() + pictures, pictureBytes, err := planPictures(ctx, r, s) + if err != nil { + return format.Plan{}, err + } + min := s.layout().fixedBytes() + pictureBytes if r.Bytes < min { return format.Plan{}, belowMinimum(s, r.Bytes, min) } @@ -78,7 +90,8 @@ func (generator) Plan(r format.Request) (format.Plan, error) { Determinism: format.DeterminismByte, Properties: s.manifest(), } - m := memo{settings: s, seed: r.Seed} + describePictures(p.Properties, s.pictures, pictures) + m := memo{settings: s, seed: r.Seed, pictures: pictures, pictureBytes: pictureBytes} if r.Label { line := labelLine(core.Label("rtf", r.Bytes, r.Seed)) if min+int64(len(line)) <= r.Bytes { @@ -105,9 +118,17 @@ func labelLine(label string) string { // belowMinimum is the refusal of a size too small for these settings. func belowMinimum(s settings, requested, min int64) error { reason := core.Says("rtf.MinimumDocument", "a document holds its header and its font table, and the two need that much") - if s.adds() { - reason = core.Says("rtf.MinimumWithSettings", "a document holds its header and its font table and what %s, %s and %s add to them, and together they need that much", - core.A("Characters", core.LabelTerm(Characters)), core.A("Elements", core.LabelTerm(Elements)), core.A("Nesting", core.LabelTerm(Nesting))) + switch { + case s.pictures.Count > 0 && s.binary: + // The pictures are what a tester raises the size for, so they are what + // the reason names, whatever else was asked - the way a page does. + reason = core.Says("rtf.MinimumWithBinaryPictures", "a document holds its header, its font table and every picture byte for byte, and together they need that much") + case s.pictures.Count > 0: + reason = core.Says("rtf.MinimumWithPictures", "a document holds its header, its font table and every picture in hex, which is twice the size of the picture itself, and together they need that much") + case s.adds(): + reason = core.Says("rtf.MinimumWithSettings", "a document holds its header and its font table and what %s, %s, %s and %s add to them, and together they need that much", + core.A("Characters", core.LabelTerm(Characters)), core.A("Elements", core.LabelTerm(Elements)), core.A("Nesting", core.LabelTerm(Nesting)), + core.A("Links", core.LabelTerm(Links))) } return &format.BelowMinimumError{ Format: "RTF", @@ -135,7 +156,13 @@ func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error { if err := core.WriteAll(w, []byte(l.body)); err != nil { return err } - filled := p.Bytes - l.fixedBytes() - int64(len(m.labelLine)) + if err := writeLinks(ctx, w, m.settings.links); err != nil { + return err + } + if err := writePictures(ctx, w, m.pictures); err != nil { + return err + } + filled := p.Bytes - l.fixedBytes() - m.pictureBytes - int64(len(m.labelLine)) if err := core.FillRecords(ctx, w, core.NewRand(m.seed), filled, blocks{}); err != nil { return err } @@ -147,13 +174,15 @@ func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error { // layout is what a document of these settings holds whatever its size: the // header with the font table, and with every the properties, the page header -// and the footer, the groups of nesting, and the paragraphs the settings add. -// The blocks and the closing words go between the paragraphs and the braces -// that close the groups, and the last brace closes the document. +// and the footer, the groups of nesting, the paragraphs the settings add and +// the links. The pictures, planned apart because each is a file of its own, +// the blocks and the closing words go between those and the braces that close +// the groups, and the last brace closes the document. type layout struct { head string levels int body string + links int64 } func (s settings) layout() layout { @@ -164,6 +193,7 @@ func (s settings) layout() layout { l := layout{ head: `{\rtf1\ansi\ansicpg` + strconv.Itoa(declaredCodePage) + `\deff0\uc1` + fonts + "\r\n", levels: s.nesting, + links: linksBytes(s.links), } if s.every { l.head += catalogueHead @@ -177,10 +207,11 @@ func (s settings) layout() layout { return l } -// fixedBytes is everything but the label, the blocks and the closing words: the -// smallest document of these settings without its label. +// fixedBytes is everything but the label, the pictures, the blocks and the +// closing words: the smallest document of these settings without its label and +// its pictures. func (l layout) fixedBytes() int64 { - return int64(len(l.head)) + 2*int64(l.levels) + int64(len(l.body)) + 1 + return int64(len(l.head)) + 2*int64(l.levels) + int64(len(l.body)) + l.links + 1 } // minimumBytes is the smallest document, which is what the registry declares. diff --git a/internal/format/rtf/settings.go b/internal/format/rtf/settings.go index e39d87e9..92d52ce9 100644 --- a/internal/format/rtf/settings.go +++ b/internal/format/rtf/settings.go @@ -5,6 +5,7 @@ import ( "github.com/donislawdev/TestingFilesGenerator/internal/core" "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/format/picture" ) // The settings of a document and what each of them takes. The names and the @@ -37,6 +38,20 @@ const ( // CodePage is the manifest key of the code page the header declares. CodePage = "code_page" + + // Links, PictureData and its two values were approved by the owner on + // 2026-10-15 after six readers were measured (docs/RTF-2-2026-10-15.md + // sections 1 and 9). The pictures themselves take the names a mail + // message and a page give them (package picture). + Links = "links" + // MaxLinks is the ceiling links has in a page, so the same name has the + // same range. No reader measured refused a million or lost one it + // returns, and LibreOffice took over eleven minutes over them. + MaxLinks = 1_000_000 + + PictureData = "picture_data" + Hex = "hex" + Binary = "binary" ) // declaredCodePage is what \ansicpg says. With codepage the fonts carry code @@ -52,12 +67,23 @@ type settings struct { encoding string every bool nesting int + // links is how many link fields the list carries, none when it is 0. + links int + // pictures are the pictures the document shows, and binary says their + // bytes are written as they are rather than in hex. + pictures picture.Settings + binary bool } // properties is the declaration of every setting, in the order both surfaces // show them: what the text says, how it is written, what the document holds, -// and how deep. +// how deep, its links, and then the pictures in it. func properties() []format.Property { + return append(ownProperties(), pictureProperties()...) +} + +// ownProperties are the settings of the text of the document. +func ownProperties() []format.Property { return []format.Property{{ Name: Characters, Kind: format.PropertyChoice, Choices: []string{ASCII, International}, @@ -93,6 +119,11 @@ func properties() []format.Property { Default: "0", Detail: "How many groups the content sits inside, each one inside the last. 0 puts it straight in the document, and the label stays outside so the file still says what it is. " + "No reader measured lost the content at 10 000 000 levels, but LibreOffice took 18.5 GB of memory over them and pandoc 14.5 GB, against about 2 GB each at 1 000 000.", + }, { + Name: Links, Kind: format.PropertyInt, + Min: 0, Max: MaxLinks, + Default: "0", + Detail: linksDetail, }} } @@ -112,17 +143,28 @@ func settingsOf(props map[string]string) (settings, error) { } values[p.Name] = v } + // Allows has already said each number is a whole number in range, so a + // value that still does not parse is this package's mistake. n, err := strconv.Atoi(values[Nesting]) if err != nil { - // Allows has already said the value is a whole number in range, so a - // value that still does not parse is this package's mistake. return settings{}, core.Defect(err) } + links, err := strconv.Atoi(values[Links]) + if err != nil { + return settings{}, core.Defect(err) + } + pictures, err := picture.Parse(values) + if err != nil { + return settings{}, err + } return settings{ international: values[Characters] == International, encoding: values[TextEncoding], every: values[Elements] == Every, nesting: n, + links: links, + pictures: pictures, + binary: values[PictureData] == Binary, }, nil } @@ -142,24 +184,29 @@ func valueOf(props map[string]string, p format.Property) (string, error) { // 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 := ASCII, Basic + chars, elements, data := ASCII, Basic, Hex if s.international { chars = International } if s.every { elements = Every } + if s.binary { + data = Binary + } return map[string]any{ Characters: chars, TextEncoding: s.encoding, Elements: elements, Nesting: s.nesting, CodePage: declaredCodePage, + Links: s.links, + PictureData: data, } } // adds says the settings put something into the document beyond the header, // which is what the refusal of a size too small has to name. func (s settings) adds() bool { - return s.international || s.every || s.nesting > 0 + return s.international || s.every || s.nesting > 0 || s.links > 0 } diff --git a/internal/guard/containerstop_test.go b/internal/guard/containerstop_test.go index ea04fdfd..805ce1e5 100644 --- a/internal/guard/containerstop_test.go +++ b/internal/guard/containerstop_test.go @@ -10,6 +10,7 @@ import ( "github.com/donislawdev/TestingFilesGenerator/internal/core" "github.com/donislawdev/TestingFilesGenerator/internal/engine" "github.com/donislawdev/TestingFilesGenerator/internal/format" + pic "github.com/donislawdev/TestingFilesGenerator/internal/format/picture" ) // A format that holds other files stops planning them when the run is stopped. @@ -52,17 +53,40 @@ func (c *countdown) Err() error { 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. +// containerRequests are the formats that plan files inside them, each asked for +// forty, which a live context plans in well under a second. The archives by +// name, since what they hold is a request of its own. Every format that +// carries pictures from the registry, by the setting it declares: until +// 2026-10-15 the list named html and eml by hand, and rtf, the third to carry +// pictures, would have passed it unasked (docs/RTF-2-2026-10-15.md 2.4). 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}, + out := map[string]format.Request{ "zip": {Bytes: 1 << 20, Seed: 7741, Label: true, Contains: forty}, "targz": {Bytes: 1 << 20, Seed: 7741, Label: true, Contains: forty}, } + for _, d := range format.All() { + for _, p := range d.Properties { + if p.Name == pic.Count { + out[d.ID] = format.Request{Bytes: 1 << 20, Seed: 7741, Label: true, Properties: pictures} + } + } + } + return out +} + +// TestEveryFormatThatCarriesPicturesIsAskedToStop holds the list above to what +// it says: the three formats known to carry pictures are among those asked, so +// a registry that stopped declaring the setting cannot shrink the guard to the +// archives in silence. +func TestEveryFormatThatCarriesPicturesIsAskedToStop(t *testing.T) { + asked := containerRequests() + for _, id := range []string{"html", "eml", "rtf"} { + if _, ok := asked[id]; !ok { + t.Errorf("%s carries pictures and is not among the containers asked to stop", id) + } + } } // TestAContainerStopsBetweenTheFilesItPlans asks each container twice. With a diff --git a/internal/guard/generatorbytes_test.go b/internal/guard/generatorbytes_test.go index 36b2b9d9..7082a2ed 100644 --- a/internal/guard/generatorbytes_test.go +++ b/internal/guard/generatorbytes_test.go @@ -426,6 +426,17 @@ func rtfGoldenCases() map[string]engine.Target { "rtf_odd_clean": {ID: "g", Format: "rtf", Sizes: engine.Uniform(1, 8193)}, "rtf_32kib_everything": {ID: "g", Format: "rtf", Sizes: engine.Uniform(1, 32768), Label: true, Properties: map[string]string{"characters": "international", "text_encoding": "both", "elements": "every", "nesting": "50"}}, + // The pictures in hex and binary, the links, and all of it at once with + // the settings of the first piece (docs/RTF-2-2026-10-15.md). + "rtf_16kib_pictures": {ID: "g", Format: "rtf", Sizes: engine.Uniform(1, 16384), Label: true, + Properties: map[string]string{"inline": "2", "inline_size": "2kb"}}, + "rtf_16kib_binary_jpg": {ID: "g", Format: "rtf", Sizes: engine.Uniform(1, 16384), Label: true, + Properties: map[string]string{"inline": "1", "inline_format": "jpg", "inline_size": "2kb", "picture_data": "binary"}}, + "rtf_16kib_links": {ID: "g", Format: "rtf", Sizes: engine.Uniform(1, 16384), Label: true, + Properties: map[string]string{"links": "40"}}, + "rtf_64kib_all": {ID: "g", Format: "rtf", Sizes: engine.Uniform(1, 65536), Label: true, + Properties: map[string]string{"links": "18", "inline": "2", "inline_size": "4kb", "picture_data": "binary", + "characters": "international", "elements": "every", "nesting": "50"}}, } } diff --git a/internal/guard/layers_test.go b/internal/guard/layers_test.go index f4d084ac..781ee2f3 100644 --- a/internal/guard/layers_test.go +++ b/internal/guard/layers_test.go @@ -190,7 +190,7 @@ var sameLayerAllowed = map[string][]string{ "internal/format/zip": {"internal/format", "internal/format/imagelabel", "internal/format/archive"}, "internal/format/targz": {"internal/format", "internal/format/archive"}, "internal/format/eml": {"internal/format", "internal/format/archive", "internal/format/picture"}, - "internal/format/rtf": {"internal/format"}, + "internal/format/rtf": {"internal/format", "internal/format/picture"}, "internal/format/tiff": {"internal/format", "internal/format/imagelabel", "internal/format/imagedim"}, "internal/format/webp": {"internal/format", "internal/format/imagelabel", "internal/format/imagedim"}, "internal/format/avif": {"internal/format", "internal/format/imagelabel", "internal/format/imagedim", "internal/format/isobmff"}, diff --git a/internal/guard/notelemetry_test.go b/internal/guard/notelemetry_test.go index 5b93665a..d25bb2a4 100644 --- a/internal/guard/notelemetry_test.go +++ b/internal/guard/notelemetry_test.go @@ -178,6 +178,17 @@ var allowedURLs = map[string]string{ // RFC 2606 keeps for examples. A field is never followed by opening the // document, and this program never follows it (docs/RTF-2026-10-15.md). "internal/format/rtf/catalogue.go|https://example.com/tfg/link": "a link written into a document, on a domain kept for examples", + // The kinds of link the list of an RTF document goes through, every one on + // a domain RFC 2606 keeps for examples (docs/RTF-2-2026-10-15.md 2.5). + "internal/format/rtf/links.go|https://example.com/path/page.html?a=1&b=2#part": "a link written into a document, on a domain kept for examples", + "internal/format/rtf/links.go|https://example.com/path/page.html": "a link written into a document, on a domain kept for examples", + "internal/format/rtf/links.go|http://example.org/": "a link written into a document, on a domain kept for examples", + "internal/format/rtf/links.go|https://example.com/caf%C3%A9?q=two%20words": "a link written into a document, on a domain kept for examples", + "internal/format/rtf/links.go|https://xn--bcher-kva.example/": "a link written into a document, on a top level domain kept for examples", + "internal/format/rtf/links.go|https://example.com:8443/port": "a link written into a document, on a domain kept for examples", + "internal/format/rtf/links.go|ftp://example.com/file.txt": "a link written into a document, on a domain kept for examples", + "internal/format/rtf/links.go|https://example.org/new": "a link written into a document, on a domain kept for examples", + "internal/format/rtf/links.go|https://example.net/tip": "a link written into a document, on a domain kept for examples", "internal/legal/companions.go|https://github.com/pal1000/mesa-dist-win/releases/download/26.2.0/mesa3d-26.2.0-release-msvc.7z": "where the software " + "renderer's bytes were downloaded FROM, by the release workflow and never by the program: written into the bill of " + "materials as the package's download location, and compared with the workflow's pin by a guard", diff --git a/internal/guard/parity_test.go b/internal/guard/parity_test.go index b2170b90..4a977df3 100644 --- a/internal/guard/parity_test.go +++ b/internal/guard/parity_test.go @@ -181,7 +181,12 @@ var reachableFromTheWindow = []string{ "property:pptx.slides", "property:rtf.characters", "property:rtf.elements", + "property:rtf.inline", + "property:rtf.inline_format", + "property:rtf.inline_size", + "property:rtf.links", "property:rtf.nesting", + "property:rtf.picture_data", "property:rtf.text_encoding", "property:xlsx.columns", "property:xlsx.rows", diff --git a/internal/guard/rtf_test.go b/internal/guard/rtf_test.go index a37ee0c9..2c932cfc 100644 --- a/internal/guard/rtf_test.go +++ b/internal/guard/rtf_test.go @@ -33,6 +33,13 @@ var rtfCases = []map[string]string{ {rtf.Nesting: "1000"}, {rtf.Characters: rtf.International, rtf.TextEncoding: rtf.Both, rtf.Elements: rtf.Every, rtf.Nesting: "50"}, {rtf.Characters: rtf.International, rtf.TextEncoding: rtf.Codepage, rtf.Elements: rtf.Every}, + // The pictures and the links (docs/RTF-2-2026-10-15.md), small enough that + // a document of 20 000 B still has room for blocks. + {"inline": "2", "inline_size": "2kb"}, + {"inline": "1", "inline_format": "jpg", "inline_size": "2kb"}, + {"inline": "2", "inline_size": "2kb", rtf.PictureData: rtf.Binary}, + {rtf.Links: "40"}, + {rtf.Links: "18", "inline": "1", "inline_size": "1kb", rtf.Characters: rtf.International, rtf.Elements: rtf.Every, rtf.Nesting: "5"}, } // rtfPolish, rtfRussian, rtfJapanese and rtfArabic are the sentences @@ -83,7 +90,7 @@ func TestEveryRTFSettingSurvivesItsReferenceTool(t *testing.T) { if err := os.WriteFile(path, b, 0o600); err != nil { t.Fatal(err) } - s, pd := rtfSurvivesReaders(t, path, props, p, c.size) + s, pd := rtfSurvivesReaders(t, path, b, props, p, c.size) strict.Add(int64(s)) byPandoc.Add(int64(pd)) } @@ -98,7 +105,7 @@ func TestEveryRTFSettingSurvivesItsReferenceTool(t *testing.T) { // rtfSurvivesReaders asks the checker and pandoc about one document and says // which of them read it. -func rtfSurvivesReaders(t *testing.T, path string, props map[string]string, p format.Plan, size int64) (strict, byPandoc int) { +func rtfSurvivesReaders(t *testing.T, path string, body []byte, props map[string]string, p format.Plan, size int64) (strict, byPandoc int) { t.Helper() labelled, _ := p.Properties[format.PropertyLabelEmbedded].(bool) s := oracle.Strict("rtf", path, rtfSettings(props, labelled)...) @@ -108,6 +115,12 @@ func rtfSurvivesReaders(t *testing.T, path string, props map[string]string, p fo if s.Err != nil { t.Fatalf("%v at %d B: %v", props, size, s.Err) } + if props["inline"] != "" && props["inline"] != "0" && props[rtf.PictureData] == rtf.Binary { + if rtfPandocBreaksBinaryPictures(t, path, body, props, size) { + return 1, 1 + } + return 1, 0 + } text, warnings, res := oracle.PandocRTF(path) if !res.Available { return 1, 0 @@ -119,9 +132,55 @@ func rtfSurvivesReaders(t *testing.T, path string, props map[string]string, p fo t.Fatalf("%v at %d B: the manifest says the label is there %v, and pandoc read it %v", props, size, labelled, got) } rtfPandocReadings(t, norm.NFC.String(text), props, size) + rtfPandocKeepsHexPictures(t, path, body, props, size) return 1, 1 } +// rtfPandocKeepsHexPictures is pandoc's reading of pictures in hex, measured on +// its three versions: every picture written out as the very file the document +// carries, which is what the sentence of inline says of it. +func rtfPandocKeepsHexPictures(t *testing.T, path string, body []byte, props map[string]string, size int64) { + t.Helper() + want := rtfPicturesOf(t, body) + got, warnings, res := oracle.PandocRTFPictures(path, t.TempDir()) + if !res.Available { + return + } + if res.Err != nil || warnings != "" || len(got) != len(want) { + t.Fatalf("%v at %d B: pandoc read %d of %d pictures, %v %s", props, size, len(got), len(want), res.Err, warnings) + } + for i := range want { + if !bytes.Equal(got[i], want[i].data) { + t.Fatalf("%v at %d B: pandoc wrote picture %d out as %d B other than the %d B the document carries", props, size, i+1, len(got[i]), len(want[i].data)) + } + } +} + +// rtfPandocBreaksBinaryPictures is the canary of picture_data: pandoc was +// measured on three versions to turn the line ends inside a binary picture +// into line feeds, so no png came out whole, and to refuse the document once +// the shift reached the closing brace. The day it reads one whole, the +// sentence that says so has to change. Says whether pandoc was there to ask. +func rtfPandocBreaksBinaryPictures(t *testing.T, path string, body []byte, props map[string]string, size int64) bool { + t.Helper() + want := rtfPicturesOf(t, body) + got, _, res := oracle.PandocRTFPictures(path, t.TempDir()) + if !res.Available { + return false + } + if res.Err != nil { + return true + } + for i, g := range got { + for _, w := range want { + if bytes.Equal(g, w.data) { + t.Fatalf("%v at %d B: pandoc wrote picture %d out whole from binary, and picture_data says no png comes out whole - the sentence has to change", props, size, i+1) + } + } + } + return true +} + // rtfPandocReadings is what pandoc was measured to make of the paragraphs in // other scripts and of the catalogue of every part. func rtfPandocReadings(t *testing.T, text string, props map[string]string, size int64) { @@ -218,6 +277,10 @@ func TestTheRTFManifestRecordsEverySetting(t *testing.T) { want := map[string]any{ "characters": "ascii", "text_encoding": "unicode", "elements": "basic", "nesting": 0, "code_page": 1252, format.PropertyLabelEmbedded: true, + // The second piece, approved the same day (docs/RTF-2-2026-10-15.md 9), + // and the five keys of the pictures a page and a message carry too. + "links": 0, "picture_data": "hex", + "inline": 0, "inline_format": "png", "inline_size": int64(8192), "inline_bytes": int64(0), "inline_encoded_bytes": int64(0), } if len(p.Properties) != len(want) { t.Errorf("the manifest carries %d keys, %v, and the approved list is %d", len(p.Properties), p.Properties, len(want)) @@ -240,7 +303,7 @@ func TestAnRTFCutAtAnySizeEndsOnItsBraces(t *testing.T) { if err != nil { t.Fatal(err) } - for _, props := range []map[string]string{{}, {rtf.Nesting: "3"}, {rtf.Characters: rtf.International, rtf.Elements: rtf.Every}} { + for _, props := range []map[string]string{{}, {rtf.Nesting: "3"}, {rtf.Characters: rtf.International, rtf.Elements: rtf.Every}, {rtf.Links: "18"}} { smallest := d.SmallestAccepted(format.Request{Properties: props, Label: true}) closing := 1 if props[rtf.Nesting] != "" { diff --git a/internal/guard/rtfpictures_test.go b/internal/guard/rtfpictures_test.go new file mode 100644 index 00000000..467b0230 --- /dev/null +++ b/internal/guard/rtfpictures_test.go @@ -0,0 +1,247 @@ +package guard + +import ( + "bytes" + "encoding/hex" + "errors" + "fmt" + "image" + _ "image/jpeg" // DecodeConfig of the jpg pictures + _ "image/png" // and of the png ones + "regexp" + "strconv" + "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/format/rtf" +) + +// The pictures an RTF document carries, from 2026-10-15 +// (docs/RTF-2-2026-10-15.md). Read from the bytes, with no tool outside this +// process, so these run wherever the suite does. + +// rtfPictureHead is one picture's paragraph up to its data, in the shape +// LibreOffice writes a picture: the name, the format, the size in pixels and on +// the page, and with binary the count of bytes that follow. +var rtfPictureHead = regexp.MustCompile(`inline_([0-9]{4,5})(\.[a-z]+) \{\\pict\\([a-z]+)\\picw([0-9]+)\\pich([0-9]+)\\picwgoal([0-9]+)\\pichgoal([0-9]+)(?:\\bin([0-9]+) )?`) + +// rtfPicture is one picture as a document carries it. +type rtfPicture struct { + name, blip string + w, h, gw, gh int + binary bool + data []byte + longestHexLine int + hexLineCount int + closedByTheBrace bool +} + +// rtfPicturesOf reads every picture out of a document, in order, each from +// where the last one ended, so the bytes of a binary picture are never taken +// for the start of another. +func rtfPicturesOf(t *testing.T, body []byte) []rtfPicture { + t.Helper() + var out []rtfPicture + for pos := 0; ; { + m := rtfPictureHead.FindSubmatchIndex(body[pos:]) + if m == nil { + return out + } + field := func(i int) string { return string(body[pos+m[2*i] : pos+m[2*i+1]]) } + number := func(i int) int { n, _ := strconv.Atoi(field(i)); return n } + p := rtfPicture{name: "inline_" + field(1) + field(2), blip: field(3), + w: number(4), h: number(5), gw: number(6), gh: number(7), binary: m[16] >= 0} + k := pos + m[1] + if p.binary { + n := number(8) + p.data = body[k:min(k+n, len(body))] + k += len(p.data) + } else { + var digits []byte + for bytes.HasPrefix(body[k:], []byte("\r\n")) { + end := k + 2 + for end < len(body) && strings.IndexByte("0123456789abcdef", body[end]) >= 0 { + end++ + } + p.longestHexLine = max(p.longestHexLine, end-k-2) + p.hexLineCount++ + digits = append(digits, body[k+2:end]...) + k = end + } + var err error + if p.data, err = hex.DecodeString(string(digits)); err != nil { + t.Fatalf("%s is not hex: %v", p.name, err) + } + } + p.closedByTheBrace = k < len(body) && body[k] == '}' + out = append(out, p) + pos = k + } +} + +// TestAPictureInAnRTFIsTheFileItsFormatWritesAlone is the claim of inline in +// both ways a picture's bytes can be written. +// +// The document carries as many pictures as were ordered, each named for its +// place and its format, in the word RTF names the format by, written the way +// picture_data says, its data closed by the brace of its group, 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, not something that only looks +// like one. Its size in pixels is the file's own, and on the page it stands at +// 96 pixels to the inch, no wider than the column of the page RTF assumes when +// a document names none (owner's decision of 2026-10-15). +func TestAPictureInAnRTFIsTheFileItsFormatWritesAlone(t *testing.T) { + blips := map[string]string{"png": "pngblip", "jpg": "jpegblip"} + for id, blip := range blips { + desc, err := format.Get(id) + if err != nil { + t.Fatal(err) + } + for _, data := range []string{rtf.Hex, rtf.Binary} { + props := map[string]string{"inline": "2", "inline_format": id, "inline_size": "2kb", rtf.PictureData: data} + found := rtfPicturesOf(t, writeEncoded(t, "rtf", 65536, props)) + if len(found) != 2 { + t.Errorf("%s %s: inline=2 was ordered and the document carries %d pictures", id, data, len(found)) + continue + } + for i, p := range found { + where := fmt.Sprintf("%s %s picture %d", id, data, i+1) + if want := fmt.Sprintf("inline_%04d%s", i+1, desc.Extension); p.name != want || p.blip != blip { + t.Errorf("%s is %s with \\%s, and %s with \\%s was ordered", where, p.name, p.blip, want, blip) + } + if p.binary != (data == rtf.Binary) || !p.closedByTheBrace { + t.Errorf("%s: binary %v, closed by its brace %v", where, p.binary, p.closedByTheBrace) + } + if !p.binary && (p.longestHexLine != 128 || p.hexLineCount != (2*2048+127)/128) { + t.Errorf("%s: %d lines of hex, the longest %d digits, and 2 KiB is %d lines of 128", where, p.hexLineCount, p.longestHexLine, (2*2048+127)/128) + } + if alone := htmlPictureAlone(t, desc, core.FileSeed(7741, i)); !bytes.Equal(p.data, alone) { + t.Errorf("%s is %d B and not the file the format writes on its own with the seed of its place", where, len(p.data)) + } + rtfPictureSize(t, where, p) + } + } + } +} + +// rtfPictureSize holds a picture's sizes to the file it is: in pixels its own, +// and on the page 15 twips to a pixel, or the column's 8 640 twips wide with +// the height in proportion. +func rtfPictureSize(t *testing.T, where string, p rtfPicture) { + t.Helper() + c, _, err := image.DecodeConfig(bytes.NewReader(p.data)) + if err != nil { + t.Errorf("%s does not decode: %v", where, err) + return + } + gw, gh := c.Width*15, c.Height*15 + if gw > 8640 { + gw, gh = 8640, c.Height*8640/c.Width + } + if p.w != c.Width || p.h != c.Height || p.gw != gw || p.gh != gh { + t.Errorf("%s says %dx%d pixels and %dx%d twips, and the file is %dx%d, which is %dx%d", where, p.w, p.h, p.gw, p.gh, c.Width, c.Height, gw, gh) + } +} + +// TestAnRTFPictureStaysInsideTheColumn is the other side of the rule above, +// from the sizes either side of the column: a picture whose width at 96 pixels +// to the inch is inside the column keeps it, and the largest one, 640 pixels, +// is narrowed to the column. Both are asserted to be there, so the guard is in +// the state it is about rather than passing on pictures that never reach it. +func TestAnRTFPictureStaysInsideTheColumn(t *testing.T) { + var narrowed, kept bool + for _, size := range []string{"1kb", "8kb"} { + for _, p := range rtfPicturesOf(t, writeEncoded(t, "rtf", 65536, map[string]string{"inline": "1", "inline_size": size})) { + rtfPictureSize(t, "png of "+size, p) + narrowed = narrowed || (p.w*15 > 8640 && p.gw == 8640) + kept = kept || (p.w*15 <= 8640 && p.gw == p.w*15) + } + } + if !narrowed || !kept { + t.Errorf("no picture was narrowed to the column (%v) or none kept its own width (%v), so one side of the rule went unasked", narrowed, kept) + } +} + +// TestAnRTFRefusesPicturesItCannotCarry holds the refusal of every format but +// the two RTF names, with no pictures ordered as well: a value the setting +// cannot take is a mistake either way, as it is in a page and a message. The +// formats come from the registry, so a picture format added tomorrow is asked +// about too, and png and jpg are asked to be taken, so the refusal is about the +// format rather than about everything. +func TestAnRTFRefusesPicturesItCannotCarry(t *testing.T) { + d, err := format.Get("rtf") + if err != nil { + t.Fatal(err) + } + asked := 0 + for _, id := range append(pic.Formats(), "txt") { + for _, count := range []string{"0", "1"} { + _, err := d.Generator.Plan(format.Request{Bytes: 65536, Seed: 7741, + Properties: map[string]string{"inline": count, "inline_format": id}}) + if id == "png" || id == "jpg" { + if err != nil { + t.Errorf("inline=%s with inline_format=%s was refused: %v", count, id, err) + } + continue + } + asked++ + var bad *format.PropertyValueError + if !errors.As(err, &bad) || bad.Key != "inline_format" || !strings.Contains(bad.Remedy.String(), "png, jpg") { + t.Errorf("inline=%s with inline_format=%s was answered with %v, not a refusal naming png and jpg", count, id, err) + } + } + } + if asked < 4 { + t.Errorf("only %d refusals were asked about, so the build has hardly any picture format RTF does not name", asked) + } +} + +// TestAnRTFOfPicturesTooLargeToCountIsRefused holds the ceiling the sums are +// counted under, both ways past it: one jpg picture so large that twice it, in +// hex, does not fit in an int64 - without the check of each picture on its own +// it came back planned, measured on 2026-10-15 - and one under the ceiling that +// hex makes twice as large. jpg has no ceiling of its own, png refuses past one +// chunk of about 2 GB. A document planned past it would be counted in an int64 +// that wrapped round, a size that is a lie rather than a refusal. One picture +// rather than ten thousand: a jpg of that size takes milliseconds to plan, and +// ten thousand of them took 34 seconds. +func TestAnRTFOfPicturesTooLargeToCountIsRefused(t *testing.T) { + d, err := format.Get("rtf") + if err != nil { + t.Fatal(err) + } + for _, size := range []string{"8000000000gb", "1000000000gb"} { + _, err = d.Generator.Plan(format.Request{Bytes: 65536, Seed: 7741, + Properties: map[string]string{"inline": "1", "inline_format": "jpg", "inline_size": size}}) + var above *format.AboveMaximumError + if !errors.As(err, &above) || above.Maximum != int64(1)<<60 { + t.Errorf("one jpg picture of %s in hex was answered with %v, not a refusal at the ceiling", size, err) + } + } +} + +// TestAnRTFTooSmallForItsPicturesSaysHowTheyAreWritten holds the refusal a +// tester meets first when ordering pictures: the minimum of that order, and a +// reason that says why it is that large - twice the picture in hex, the picture +// itself in binary - and the minimum is then accepted. +func TestAnRTFTooSmallForItsPicturesSaysHowTheyAreWritten(t *testing.T) { + d, err := format.Get("rtf") + if err != nil { + t.Fatal(err) + } + for data, says := range map[string]string{rtf.Hex: "in hex", rtf.Binary: "byte for byte"} { + props := map[string]string{"inline": "1", rtf.PictureData: data} + _, err := d.Generator.Plan(format.Request{Bytes: 1024, Seed: 7741, Properties: props}) + var below *format.BelowMinimumError + if !errors.As(err, &below) || !strings.Contains(below.Reason.String(), says) { + t.Errorf("%s: 1 KiB with an 8 KiB picture was answered with %v, not a refusal saying %q", data, err, says) + continue + } + if _, err := d.Generator.Plan(format.Request{Bytes: below.Minimum, Seed: 7741, Properties: props}); err != nil { + t.Errorf("%s: the minimum %d B the refusal names was itself refused: %v", data, below.Minimum, err) + } + } +} diff --git a/internal/guard/testdata/generator-golden.json b/internal/guard/testdata/generator-golden.json index da1bfbb8..fb796f30 100644 --- a/internal/guard/testdata/generator-golden.json +++ b/internal/guard/testdata/generator-golden.json @@ -605,6 +605,26 @@ "bytes": 32768, "sha256": "6451c067b5a67e9a8707228d3782aa0a9abca538cfa5bd0be4ece2dbf26a9c5d", "measured_on": "2026-10-15" + }, + "rtf_16kib_pictures": { + "bytes": 16384, + "sha256": "4bc01c58e221c868c20060390f853574b268d34de43509e38a3fd0e616e0bc6d", + "measured_on": "2026-10-15" + }, + "rtf_16kib_binary_jpg": { + "bytes": 16384, + "sha256": "d3853c5fb7dd2a39f57f595d91782e66a19f65efc295491342136bc2d554a8cd", + "measured_on": "2026-10-15" + }, + "rtf_16kib_links": { + "bytes": 16384, + "sha256": "d5fcbce7f249bc0877c2adceaa46d4f8f6a64d7125473c49a226bc27a6ab5b7a", + "measured_on": "2026-10-15" + }, + "rtf_64kib_all": { + "bytes": 65536, + "sha256": "872b64b301062ec7a49840ecac682f8e5e5c99ba40328bb24a2c0a792b6ca6b1", + "measured_on": "2026-10-15" } }, "remeasured": [ diff --git a/internal/gui/text/locale/registry/en.json b/internal/gui/text/locale/registry/en.json index cdc3dd7f..7b804291 100644 --- a/internal/gui/text/locale/registry/en.json +++ b/internal/gui/text/locale/registry/en.json @@ -994,6 +994,16 @@ "hash": "sha256-8f65223004a7", "other": "1.7" }, + "Choice.picture_data.binary": { + "description": "One entry of the picture_data 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-9a3a45d01531", + "other": "binary" + }, + "Choice.picture_data.hex": { + "description": "One entry of the picture_data 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-128df13c1e54", + "other": "hex" + }, "Choice.quote_style.all": { "description": "One entry of the quote_style 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-5ef5ef0364b6", @@ -1634,11 +1644,36 @@ "hash": "sha256-b0177e3841f3", "other": "Which parts the document uses. basic is headings, paragraphs with bold and italic words, lists, tables and indented quotes. every adds document properties, a page header and footer, a footnote, a comment, hidden text, a link, a bookmark, a group every reader has to skip, a word no reader knows and line, tab, page and section breaks, and each part says where it is. Tika returns the most of them, the header, the footer and the comment among them. LibreOffice, striprtf and RichEdit return the hidden text as text, RichEdit with the address of the link, and pandoc returns the footnote and leaves the hidden text out." }, + "Detail.format/rtf.inline": { + "description": "The sentence under the inline setting of the rtf format, after what it takes.", + "hash": "sha256-10dd52bc4b54", + "other": "How many pictures the document shows, each a real file of its format in a paragraph of its own that starts with its name, such as inline_0001.png. They sit above the blocks, at 96 pixels to the inch and never wider than 6 inches. LibreOffice, pandoc and Tika return each file as it was written. RichEdit shows the same picture and writes it out again smaller." + }, + "Detail.format/rtf.inline_format": { + "description": "The sentence under the inline_format setting of the rtf format, after what it takes.", + "hash": "sha256-929d6bcd9c5e", + "other": "The format of the pictures, png or jpg - the two picture formats RTF has a name for. Any other format is refused." + }, + "Detail.format/rtf.inline_size": { + "description": "The sentence under the inline_size setting of the rtf format, after what it takes.", + "hash": "sha256-4024e523b3e9", + "other": "How big each picture is. Written in hex it takes twice as many bytes in the document, with a line end every 128 characters. Written binary it takes its own size." + }, + "Detail.format/rtf.links": { + "description": "The sentence under the links setting of the rtf format, after what it takes.", + "hash": "sha256-9e3ca1ff09e6", + "other": "How many links the document carries, each a HYPERLINK field in a paragraph of its own, above the pictures and the blocks. They go through 17 kinds in turn and then repeat - a link to a bookmark in the document, relative, to the folder above, from the root, absolute with a query and a fragment, absolute with a location, http, without a scheme, percent encoded, an international domain, a port, mailto:, tel:, ftp:, a new window, a screen tip and data: - and the text of each says which it is. Every address is on a domain kept for examples. LibreOffice turns the link without a scheme into a file:// address and rewrites the relative ones from where it saves the document. pandoc 3.12 leaves the bookmark link without an address, pandoc 3.1 and 3.7 drop every link with its text, and RichEdit returns the address of each link in front of its text. LibreOffice took over 11 minutes over a million links. The catalogue of every part has one link of its own, which this does not count." + }, "Detail.format/rtf.nesting": { "description": "The sentence under the nesting setting of the rtf format, after what it takes.", "hash": "sha256-e1d640adb186", "other": "How many groups the content sits inside, each one inside the last. 0 puts it straight in the document, and the label stays outside so the file still says what it is. No reader measured lost the content at 10 000 000 levels, but LibreOffice took 18.5 GB of memory over them and pandoc 14.5 GB, against about 2 GB each at 1 000 000." }, + "Detail.format/rtf.picture_data": { + "description": "The sentence under the picture_data setting of the rtf format, after what it takes.", + "hash": "sha256-f91b5cd2e506", + "other": "How the bytes of each picture are written, which changes nothing while inline is 0. hex writes them as text, twice as large, the way LibreOffice writes them. binary writes them as they are, so the document is no longer text, and a program that counts braces or reads it as text loses its place. pandoc changes the line ends inside a binary picture, so no png comes out whole, and it can lose the text after the picture or refuse the whole document. LibreOffice, Tika and RichEdit read both." + }, "Detail.format/rtf.text_encoding": { "description": "The sentence under the text_encoding setting of the rtf format, after what it takes.", "hash": "sha256-e6a5e55837f3", @@ -2094,6 +2129,11 @@ "hash": "sha256-7115a778ab47", "other": "Document properties" }, + "Group.format/rtf.Pictures in the document": { + "description": "The heading over a block of settings of the rtf format.", + "hash": "sha256-1652b4354ef8", + "other": "Pictures in the document" + }, "Input.checksum-check.checksum_file": { "description": "The sentence under the checksum_file box of the checksum-check tool.", "hash": "sha256-90d7baf0d228", @@ -2539,6 +2579,11 @@ "hash": "sha256-3ce7c43387ea", "other": "Pdf version" }, + "Label.picture_data": { + "description": "The name beside the box of a setting a recipe writes as picture_data.", + "hash": "sha256-b13e9c47f34a", + "other": "Picture data" + }, "Label.precision": { "description": "The name beside the box of a setting a recipe writes as precision.", "hash": "sha256-b5d65f0ae389", diff --git a/internal/gui/text/locale/registry/pl.json b/internal/gui/text/locale/registry/pl.json index bd8998d0..881fcbf5 100644 --- a/internal/gui/text/locale/registry/pl.json +++ b/internal/gui/text/locale/registry/pl.json @@ -198,6 +198,8 @@ "Choice.page_size.mixed": { "hash": "sha256-3f8fee624f43", "other": "mieszany" }, "Choice.pdf_version.1.4": { "hash": "sha256-1a948f1b4374", "other": "1.4" }, "Choice.pdf_version.1.7": { "hash": "sha256-8f65223004a7", "other": "1.7" }, + "Choice.picture_data.binary": { "hash": "sha256-9a3a45d01531", "other": "binarnie" }, + "Choice.picture_data.hex": { "hash": "sha256-128df13c1e54", "other": "hex" }, "Choice.quote_style.all": { "hash": "sha256-5ef5ef0364b6", "other": "wszystkie" }, "Choice.quote_style.minimal": { "hash": "sha256-a703788f8320", "other": "minimalne" }, "Choice.quote_style.none": { "hash": "sha256-140bedbf9c3f", "other": "brak" }, @@ -326,7 +328,12 @@ "Detail.format/pptx.slides": { "hash": "sha256-b62241b390bf", "other": "Ile slajdów zawiera prezentacja. Resztę zamówionego rozmiaru niesie niewidoczna część paczki." }, "Detail.format/rtf.characters": { "hash": "sha256-a66dbdb2f6f9", "other": "Czy dokument zawiera tekst spoza ASCII. międzynarodowe dokłada akapity po polsku, rosyjsku, japońsku i arabsku, arabski od prawej do lewej, oraz jeden z emoji, literą zapisaną dwoma punktami kodowymi, trzema znakami, które RTF poprzedza ukośnikiem, i tymi, które nazywa słowem, jak myślniki i cudzysłowy. Program bez fontu japońskiego albo arabskiego pokazuje w ich miejscu kwadraty." }, "Detail.format/rtf.elements": { "hash": "sha256-b0177e3841f3", "other": "Których części używa dokument. podstawowe to nagłówki, akapity ze słowami pogrubionymi i pochylonymi, listy, tabele i cytaty z wcięciem. wszystkie dokłada właściwości dokumentu, nagłówek i stopkę strony, przypis, komentarz, tekst ukryty, odnośnik, zakładkę, grupę, którą każdy czytnik musi pominąć, słowo, którego nie zna żaden czytnik, oraz podział wiersza, tabulator, podział strony i sekcji, a każda część mówi, gdzie jest. Tika oddaje ich najwięcej, w tym nagłówek, stopkę i komentarz. LibreOffice, striprtf i RichEdit oddają tekst ukryty jako tekst, RichEdit razem z adresem odnośnika, a pandoc oddaje przypis i pomija tekst ukryty." }, + "Detail.format/rtf.inline": { "hash": "sha256-10dd52bc4b54", "other": "Ile obrazków pokazuje dokument. Każdy jest prawdziwym plikiem swojego formatu w osobnym akapicie, który zaczyna się od jego nazwy, na przykład inline_0001.png. Leżą nad blokami, w 96 pikselach na cal i nigdy szerzej niż 6 cali. LibreOffice, pandoc i Tika oddają każdy plik tak, jak został zapisany. RichEdit pokazuje ten sam obrazek i zapisuje go od nowa, mniejszy." }, + "Detail.format/rtf.inline_format": { "hash": "sha256-929d6bcd9c5e", "other": "Format obrazków, png albo jpg - dwa formaty obrazków, które RTF nazywa. Każdy inny format jest odrzucany." }, + "Detail.format/rtf.inline_size": { "hash": "sha256-4024e523b3e9", "other": "Jak duży jest każdy obrazek. Zapisany w hex zajmuje w dokumencie dwa razy więcej bajtów, z końcem linii co 128 znaków. Zapisany binarnie zajmuje tyle, ile sam waży." }, + "Detail.format/rtf.links": { "hash": "sha256-9e3ca1ff09e6", "other": "Ile odnośników niesie dokument, każdy jako pole HYPERLINK w osobnym akapicie, nad obrazkami i blokami. Przechodzą po kolei przez 17 rodzajów i potem się powtarzają - do zakładki w dokumencie, względny, do katalogu wyżej, od korzenia, bezwzględny z zapytaniem i fragmentem, bezwzględny z położeniem, http, bez schematu, z kodowaniem procentowym, domena międzynarodowa, port, mailto:, tel:, ftp:, nowe okno, podpowiedź i data: - a tekst każdego mówi, którym jest. Każdy adres leży w domenie zarezerwowanej na przykłady. LibreOffice zamienia odnośnik bez schematu na adres file:// i przepisuje względne od miejsca, w którym zapisuje dokument. pandoc 3.12 zostawia odnośnik do zakładki bez adresu, pandoc 3.1 i 3.7 gubią każdy odnośnik razem z tekstem, a RichEdit oddaje adres każdego odnośnika przed jego tekstem. LibreOffice czytał milion odnośników ponad 11 minut. Katalog wszystkich części ma jeden własny odnośnik, którego to ustawienie nie liczy." }, "Detail.format/rtf.nesting": { "hash": "sha256-e1d640adb186", "other": "W ilu grupach, jedna w drugiej, siedzi treść. 0 umieszcza ją wprost w dokumencie, a etykieta zostaje na zewnątrz, więc plik dalej mówi, czym jest. Żaden zmierzony czytnik nie zgubił treści przy 10 000 000 poziomów, ale LibreOffice zużył na nie 18,5 GB pamięci, a pandoc 14,5 GB, wobec około 2 GB każdy przy 1 000 000." }, + "Detail.format/rtf.picture_data": { "hash": "sha256-f91b5cd2e506", "other": "Jak zapisane są bajty każdego obrazka, co niczego nie zmienia, dopóki inline wynosi 0. hex zapisuje je jako tekst, dwa razy większy, tak jak zapisuje je LibreOffice. binarnie zapisuje je takie, jakie są, więc dokument przestaje być tekstem, a program, który liczy klamry albo czyta go jako tekst, gubi się. pandoc zmienia końce linii wewnątrz obrazka zapisanego binarnie, więc żaden png nie wychodzi cały, a tekst za obrazkiem może zgubić albo odrzucić cały dokument. LibreOffice, Tika i RichEdit czytają oba zapisy." }, "Detail.format/rtf.text_encoding": { "hash": "sha256-e6a5e55837f3", "other": "Jak zapisane są litery spoza ASCII. Gdy Znaki to ascii, nic to nie zmienia. unicode zapisuje każdą jako liczbę ze znakiem zapytania po niej dla czytnika, który nie zna liczb. oba dopisuje po liczbie literę w stronie kodowej jej fontu, dla japońskiego dwa bajty, tak jak zapisuje ją Word. strona kodowa zapisuje tylko bajty strony kodowej, a emoji dalej jako liczby, bo żadna strona kodowa ich nie ma. dosłowny zapisuje znak zapytania jako zwykły znak. LibreOffice, Tika i kontrolka RichEdit z Windows czytają wszystkie cztery. pandoc pokazuje tekst ze strony kodowej złymi literami i gubi znak po każdej literze zapisanej dosłownie, a pandoc 3.1 i 3.7 gubią emoji we wszystkich czterech. striprtf oddaje emoji jako dwie połówki, których Python nie zapisze w UTF-8." }, "Detail.format/svg.height": { "hash": "sha256-093ec883d633", "other": "Jaką wysokość deklaruje rysunek. Etykieta biegnie wzdłuż dolnej krawędzi, więc rysunek niższy od tego pasa nie ma widocznej etykiety." }, "Detail.format/svg.width": { "hash": "sha256-5dc327958f8d", "other": "Jaką szerokość deklaruje rysunek. Nic nie jest tu rysowane w pikselach, więc duża liczba kosztuje kilka bajtów w pliku i bardzo dużo pamięci w tym, co go otworzy." }, @@ -418,6 +425,7 @@ "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" }, + "Group.format/rtf.Pictures in the document": { "hash": "sha256-1652b4354ef8", "other": "Obrazki w dokumencie" }, "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." }, "Input.checksum.file": { "hash": "sha256-a87fb4cce79e", "other": "Plik, którego sumy kontrolne mają zostać policzone. Jest tylko czytany." }, @@ -507,6 +515,7 @@ "Label.paragraphs": { "hash": "sha256-d2ef75e86bb7", "other": "Akapity" }, "Label.password": { "hash": "sha256-e7cf3ef4f17c", "other": "Hasło" }, "Label.pdf_version": { "hash": "sha256-3ce7c43387ea", "other": "Wersja PDF" }, + "Label.picture_data": { "hash": "sha256-b13e9c47f34a", "other": "Zapis obrazków" }, "Label.precision": { "hash": "sha256-b5d65f0ae389", "other": "Precyzja" }, "Label.producer": { "hash": "sha256-98ee4021c6f1", "other": "Producent PDF" }, "Label.quality": { "hash": "sha256-1b2c08a8733d", "other": "Jakość" }, diff --git a/internal/gui/text/locale/said/en.json b/internal/gui/text/locale/said/en.json index cb8c8d0d..4a06ce18 100644 --- a/internal/gui/text/locale/said/en.json +++ b/internal/gui/text/locale/said/en.json @@ -3251,15 +3251,50 @@ "hash": "sha256-6b426dee7162", "other": "write version: {{.SchemaVersion}}" }, + "rtf.ADocumentThisLargeCannotBe": { + "description": "Said by internal/format/rtf - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-3026003f221c", + "other": "a document this large cannot be counted to the byte in this build" + }, + "rtf.APictureInTheDocumentIs": { + "description": "Said by internal/format/rtf - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-b4581f4ef7ea", + "other": "a picture in an RTF document has to be one of the two picture formats RTF has a name for" + }, + "rtf.AskForSmallerPictures": { + "description": "Said by internal/format/rtf - 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." + }, "rtf.MinimumDocument": { "description": "Said by internal/format/rtf - a refusal under the box it is about or at the foot of the form, or a note after a run.", "hash": "sha256-0dd8e6a8cceb", "other": "a document holds its header and its font table, and the two need that much" }, + "rtf.MinimumWithBinaryPictures": { + "description": "Said by internal/format/rtf - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-2e9be5d44951", + "other": "a document holds its header, its font table and every picture byte for byte, and together they need that much" + }, + "rtf.MinimumWithPictures": { + "description": "Said by internal/format/rtf - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-af82d88ed136", + "other": "a document holds its header, its font table and every picture in hex, which is twice the size of the picture itself, and together they need that much" + }, "rtf.MinimumWithSettings": { - "description": "Said by internal/format/rtf - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Characters}}, {{.Elements}}, {{.Nesting}}, written the way the program writes them - keep each spelled exactly that way.", - "hash": "sha256-576cd6d8e42b", - "other": "a document holds its header and its font table and what {{.Characters}}, {{.Elements}} and {{.Nesting}} add to them, and together they need that much" + "description": "Said by internal/format/rtf - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Characters}}, {{.Elements}}, {{.Nesting}}, {{.Links}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-e6c4b3b7e88a", + "other": "a document holds its header and its font table and what {{.Characters}}, {{.Elements}}, {{.Nesting}} and {{.Links}} add to them, and together they need that much" + }, + "rtf.ThePictureCannotBe": { + "description": "Said by internal/format/rtf - 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-8e3654e254a6", + "other": "rtf: the {{.Format}} picture cannot be made: {{.Err}}" + }, + "rtf.UseOneOfThePictureFormats": { + "description": "Said by internal/format/rtf - 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}}." }, "textenc.HasToBeEncoding": { "description": "Said by internal/format/textenc - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.UTF8}}, {{.UTF16LE}}, {{.UTF16BE}}, written the way the program writes them - keep each spelled exactly that way.", diff --git a/internal/gui/text/locale/said/pl.json b/internal/gui/text/locale/said/pl.json index ae1479eb..2338a61b 100644 --- a/internal/gui/text/locale/said/pl.json +++ b/internal/gui/text/locale/said/pl.json @@ -647,8 +647,15 @@ "recipe.WriteTheNameAsAWord": { "hash": "sha256-effad2028c85", "other": "zapisz nazwę jako słowo" }, "recipe.WriteTheNameOnItsOwn": { "hash": "sha256-da431c8ff106", "other": "zapisz samą nazwę albo mapę zaczynającą się od type" }, "recipe.WriteVersion": { "hash": "sha256-6b426dee7162", "other": "wpisz version: {{.SchemaVersion}}" }, + "rtf.ADocumentThisLargeCannotBe": { "hash": "sha256-3026003f221c", "other": "tak dużego dokumentu ta wersja nie potrafi policzyć co do bajta" }, + "rtf.APictureInTheDocumentIs": { "hash": "sha256-b4581f4ef7ea", "other": "obrazek w dokumencie RTF musi być w jednym z dwóch formatów obrazków, które RTF nazywa" }, + "rtf.AskForSmallerPictures": { "hash": "sha256-acf878874a21", "other": "Poproś o mniej albo mniejsze obrazki." }, "rtf.MinimumDocument": { "hash": "sha256-0dd8e6a8cceb", "other": "dokument zawiera nagłówek i tablicę fontów, a te dwa potrzebują tyle" }, - "rtf.MinimumWithSettings": { "hash": "sha256-576cd6d8e42b", "other": "dokument zawiera nagłówek i tablicę fontów oraz to, co dokładają do nich {{.Characters}}, {{.Elements}} i {{.Nesting}}, a razem potrzebują tyle" }, + "rtf.MinimumWithBinaryPictures": { "hash": "sha256-2e9be5d44951", "other": "dokument zawiera nagłówek, tablicę fontów i każdy obrazek bajt w bajt, a razem potrzebują tyle" }, + "rtf.MinimumWithPictures": { "hash": "sha256-af82d88ed136", "other": "dokument zawiera nagłówek, tablicę fontów i każdy obrazek w hex, który jest dwa razy większy od samego obrazka, a razem potrzebują tyle" }, + "rtf.MinimumWithSettings": { "hash": "sha256-e6c4b3b7e88a", "other": "dokument zawiera nagłówek i tablicę fontów oraz to, co dokładają do nich {{.Characters}}, {{.Elements}}, {{.Nesting}} i {{.Links}}, a razem potrzebują tyle" }, + "rtf.ThePictureCannotBe": { "hash": "sha256-8e3654e254a6", "other": "rtf: obrazek {{.Format}} nie może powstać: {{.Err}}" }, + "rtf.UseOneOfThePictureFormats": { "hash": "sha256-a6669f6abe6b", "other": "Użyj jednego z: {{.Formats}}." }, "textenc.HasToBeEncoding": { "hash": "sha256-cf8214cf1fa6", "other": "musi to być {{.UTF8}}, {{.UTF16LE}} albo {{.UTF16BE}}" }, "tool.AToolIsNamedExactlyAnd": { "hash": "sha256-756a78c6f204", "other": "narzędzie podaje się dokładną nazwą, a najbliższa nazwa do pomylonej może robić coś innego" }, "tool.NameTheToWorkOn": { "hash": "sha256-3dac06c6319b", "other": "wskaż, na czym narzędzie ma pracować: {{.Kind}}" }, diff --git a/internal/oracle/rtfscripts.go b/internal/oracle/rtfscripts.go index a56c7c50..f955bd63 100644 --- a/internal/oracle/rtfscripts.go +++ b/internal/oracle/rtfscripts.go @@ -2,6 +2,8 @@ package oracle import ( "fmt" + "os" + "regexp" "strings" ) @@ -48,3 +50,39 @@ func PandocRTF(path string) (text, warnings string, res Result) { res = c.Check(path) return out, strings.TrimSpace(errOut), res } + +// pandocPicture is how pandoc's markdown points at a picture it wrote out. +var pandocPicture = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`) + +// PandocRTFPictures is the same reader asked for the pictures: the files it +// wrote out into dir, in the order the document shows them, with the warnings. +// A document it refused comes back with the refusal in res.Err and no +// pictures - which for a binary picture was measured to happen, and is a +// reading rather than a fault of the check (docs/RTF-2-2026-10-15.md 1.1). +func PandocRTFPictures(path, dir string) (pictures [][]byte, warnings string, res Result) { + c := pandocRTF + var out, errOut string + c.args = func(p string) []string { + return []string{"-f", "rtf", "-t", "markdown", "--wrap=none", "--extract-media=" + dir, p} + } + c.accept = func(stdout, stderr string, code int) error { + out, errOut = stdout, stderr + if code != 0 { + return fmt.Errorf("pandoc refused the document: %s", strings.TrimSpace(stderr)) + } + return nil + } + res = c.Check(path) + if res.Err != nil || !res.Available { + return nil, strings.TrimSpace(errOut), res + } + for _, m := range pandocPicture.FindAllStringSubmatch(out, -1) { + b, err := os.ReadFile(m[1]) + if err != nil { + res.Err = fmt.Errorf("pandoc points at a picture it did not write, %s: %w", m[1], err) + return nil, strings.TrimSpace(errOut), res + } + pictures = append(pictures, b) + } + return pictures, strings.TrimSpace(errOut), res +} diff --git a/internal/oracle/strict.py b/internal/oracle/strict.py index 57eae8b2..64d54fcd 100644 --- a/internal/oracle/strict.py +++ b/internal/oracle/strict.py @@ -3680,6 +3680,8 @@ def rtf_read(data, settings): allowed |= RTF_WORDS_INTERNATIONAL if settings.get("elements") == "every": allowed |= RTF_WORDS_EVERY + if settings.get("links", "0") != "0": + allowed |= RTF_LINK_WORDS fonts = rtf_fonts(data) toks = list(rtf_tokens(data)) stack, state, depth, deepest = [], RtfState(), 0, 0 @@ -3813,11 +3815,172 @@ def rtf_page_fallback(unit, back, page, enc): fail(f"the fallback of \\u{unit} is {got.hex()} and {page} writes {want.hex()}") +RTF_LINK_WORDS = {"field", "fldinst", "fldrslt", "bkmkstart", "bkmkend"} +# The kinds of link the list goes through, held here on their own rather than +# taken from the generator (docs/RTF-2-2026-10-15.md section 2.5). A switch of +# a field has its backslash escaped. +RTF_LINK_KINDS = [ + ("HYPERLINK " + RTF_BS * 2 + 'l "tfg_links"', "link to a bookmark in this document"), + ('HYPERLINK "page-2.rtf"', "relative link"), + ('HYPERLINK "../folder/page-3.rtf"', "link to the folder above"), + ('HYPERLINK "/root/page-4.rtf"', "link from the root"), + ('HYPERLINK "https://example.com/path/page.html?a=1&b=2#part"', "absolute link with a query and a fragment"), + ('HYPERLINK "https://example.com/path/page.html" ' + RTF_BS * 2 + 'l "part"', "absolute link with a location"), + ('HYPERLINK "http://example.org/"', "http link"), + ('HYPERLINK "//example.net/scheme-relative"', "link without a scheme"), + ('HYPERLINK "https://example.com/caf%C3%A9?q=two%20words"', "percent encoded link"), + ('HYPERLINK "https://xn--bcher-kva.example/"', "international domain link"), + ('HYPERLINK "https://example.com:8443/port"', "link with a port"), + ('HYPERLINK "mailto:someone@example.com?subject=Hello%20there"', "mail link"), + ('HYPERLINK "tel:+1-555-0100"', "phone link"), + ('HYPERLINK "ftp://example.com/file.txt"', "ftp link"), + ('HYPERLINK "https://example.org/new" ' + RTF_BS * 2 + 't "_blank"', "link to a new window"), + ('HYPERLINK "https://example.net/tip" ' + RTF_BS * 2 + 'o "This sentence is the screen tip of a link."', "link with a screen tip"), + ('HYPERLINK "data:text/plain,hello"', "data link"), +] +RTF_CATALOGUE_LINK = "{" + RTF_BS + "field{" + RTF_BS + "*" + RTF_BS + 'fldinst HYPERLINK "https://example.com/tfg/link"}' +# The picture formats RTF names, the word that names each and its extension. +RTF_BLIPS = {"png": ("pngblip", ".png"), "jpg": ("jpegblip", ".jpg")} +RTF_PICT = re.compile(rb"\{\\pict\\([a-z]+)\\picw([0-9]{1,5})\\pich([0-9]{1,5})\\picwgoal([0-9]{1,5})\\pichgoal([0-9]{1,5})(?:\\bin([0-9]{1,19}) )?") +RTF_HEX_LINE = re.compile(rb"\r\n([0-9a-f]{1,128})") +RTF_PICTURE_NAME = re.compile(rb"(?:^|[\n{])inline_([0-9]{4,5})(\.[a-z]+) $") + + +def rtf_cut_pictures(data): + """Every picture group cut out of the bytes before anything reads them as + text, and each read on its own: with \\bin exactly the count it names, + in hex lines of 128 digits after a line end each, and then the brace that + closes it. A picture of 100 MB in hex would be 200 million tokens, and the + bytes of a binary one are not text at all. The name in front stays in the + paragraph, where every reader shows it. + + Searched in order from where the last picture ended, so bytes inside a + binary picture are never taken for the start of another.""" + out, pictures, i = [], [], 0 + start = b"{" + RTF_BS.encode() + b"pict" + while True: + j = data.find(start, i) + if j < 0: + break + n = len(pictures) + 1 + m = RTF_PICT.match(data, j) + if not m: + fail(f"picture {n} starts in a way the generator never writes: {data[j:j + 90]!r}") + k = m.end() + if m.group(6) is not None: + size = int(m.group(6)) + raw = data[k:k + size] + if len(raw) != size: + fail(f"picture {n} says {size} B follow its \\bin, and the document ends {len(raw)} B later") + k += size + else: + lines = [] + while (line := RTF_HEX_LINE.match(data, k)) is not None: + lines.append(line.group(1)) + k = line.end() + if not lines or any(len(x) != 128 for x in lines[:-1]) or len(lines[-1]) % 2: + fail(f"picture {n} is not hex in lines of 128 digits, each after a line end: {data[m.end():m.end() + 40]!r}") + raw = bytes.fromhex(b"".join(lines).decode("ascii")) + if data[k:k + 1] != b"}": + fail(f"after the data of picture {n} stands {data[k:k + 16]!r}, not the brace that closes it") + name = RTF_PICTURE_NAME.search(data[max(0, j - 24):j]) + pictures.append({"blip": m.group(1).decode(), "w": int(m.group(2)), "h": int(m.group(3)), + "gw": int(m.group(4)), "gh": int(m.group(5)), "binary": m.group(6) is not None, + "raw": raw, "name": name and (int(name.group(1)), name.group(2).decode())}) + out.append(data[i:j]) + i = k + 1 + out.append(data[i:]) + return b"".join(out), pictures + + +def rtf_pixels(kind, raw): + """The width and height a png or jpg file says it is.""" + if kind == "png": + if raw[12:16] != b"IHDR": + fail("a png picture without IHDR where the signature ends") + return struct.unpack(">II", raw[16:24]) + i = 2 + while i + 9 <= len(raw): + if raw[i] != 0xFF: + fail(f"a jpg picture with {raw[i]:#04x} where a marker should stand, at {i}") + if raw[i + 1] in (0xC0, 0xC1, 0xC2, 0xC3): + h, w = struct.unpack(">HH", raw[i + 5:i + 9]) + return w, h + i += 2 + struct.unpack(">H", raw[i + 2:i + 4])[0] + fail("a jpg picture without a frame header to say its size") + + +def rtf_pictures(pictures, settings): + """The pictures inline ordered, TOLD rather than counted and believed: + as many as were asked, numbered in order, each of the format and size + asked, written the way picture_data says, sized in pixels as the file + says and on the page at 96 to the inch and no wider than the column, and + each a file its own check accepts.""" + count = settings.get("inline", "0") + if not re.fullmatch(r"[0-9]{1,5}", count) or int(count) > 10000: + fail(f"the rtf 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 RTF_BLIPS: + fail(f"the rtf check was told inline_format={kind!r}, and RTF names only png and jpg") + data = settings.get("picture_data", "hex") + if data not in ("hex", "binary"): + fail(f"the rtf check was told picture_data={data!r}, which is neither hex nor binary") + size = html_size(settings.get("inline_size", "8kb")) + if len(pictures) != int(count): + fail(f"inline={count} was ordered and the document carries {len(pictures)} pictures") + blip, ext = RTF_BLIPS[kind] + for n, p in enumerate(pictures, start=1): + if p["name"] != (n, ext) or p["blip"] != blip: + fail(f"picture {n} is named {p['name']} with \\{p['blip']}, and inline_{n:04d}{ext} with \\{blip} was ordered") + if p["binary"] != (data == "binary"): + fail(f"picture {n} is written {'binary' if p['binary'] else 'in hex'}, and picture_data={data} was ordered") + if len(p["raw"]) != size: + fail(f"picture {n} is {len(p['raw'])} B, and inline_size={size} B was ordered") + html_picture_verdict(kind, n, p["raw"]) + w, h = rtf_pixels(kind, p["raw"]) + gw, gh = (w * 15, h * 15) if w * 15 <= 8640 else (8640, h * 8640 // w) + if (p["w"], p["h"], p["gw"], p["gh"]) != (w, h, gw, gh): + fail(f"picture {n} says {p['w']}x{p['h']} pixels and {p['gw']}x{p['gh']} twips, and the file is {w}x{h}, which is {gw}x{gh}") + + +def rtf_links(text, settings): + """The links ordered: as many fields as were asked besides the one of the + catalogue, the kinds in turn, each with its own text, inside the bookmark + the first kind points at. + + With links 0 there is no bookmark to ask about: a basic document carrying + one is refused by the words rtf_read allows, and the only writer of the + bookmark is the loop over the links, which a count of 0 never enters.""" + raw = settings.get("links", "0") + if not re.fullmatch(r"[0-9]{1,7}", raw) or int(raw) > 1000000: + fail(f"the rtf check was told links={raw!r}, which is not a whole number from 0 to 1000000") + n = int(raw) + field = re.compile(r"\{\\field\{\\\*\\fldinst (HYPERLINK [^{}]*)\}\{\\fldrslt ([^{}]*)\}\}") + found = [(m.start(), m.end(), m.group(1), m.group(2)) for m in field.finditer(text) + if not text.startswith(RTF_CATALOGUE_LINK, m.start())] + if len(found) != n: + fail(f"links={n} was ordered and the document carries {len(found)} links besides the catalogue's") + for i, (_, _, inst, said) in enumerate(found): + want = RTF_LINK_KINDS[i % len(RTF_LINK_KINDS)] + if (inst, said) != want: + fail(f"link {i + 1} is {inst!r} saying {said!r}, and the kinds in turn give {want[0]!r} saying {want[1]!r}") + start = "{" + RTF_BS + "*" + RTF_BS + "bkmkstart tfg_links}" + end = "{" + RTF_BS + "*" + RTF_BS + "bkmkend tfg_links}" + if n == 0: + return + if text.count(start) != 1 or text.count(end) != 1: + fail("the bookmark of the list stands other than once at each end") + if text[found[0][0] - len(start):found[0][0]] != start or text[found[-1][1]:found[-1][1] + len(end)] != end: + fail("the bookmark of the list does not open right before the first link and close right after the last") + + def check_rtf(data, settings=None): - """The document is 7 bit text, its groups balance and close at the last - byte, and it holds what characters, text_encoding, elements, nesting and - the label ordered - read here by a reader of its own.""" + """The document is 7 bit text but for its binary pictures, its groups + balance and close at the last byte, and it holds what characters, + text_encoding, elements, nesting, links, the pictures and the label + ordered - read here by a reader of its own.""" settings = settings or {} + data, pictures = rtf_cut_pictures(data) bad = [b for b in data if b > 0x7E or (b < 0x20 and b not in (0x0D, 0x0A))] if bad: fail(f"{len(bad)} byte(s) outside 7 bit text, the first {bad[0]:#04x} - the generator writes every letter outside ASCII as an escape") @@ -3842,7 +4005,9 @@ def check_rtf(data, settings=None): rtf_label(paras, settings) rtf_international(paras, text, settings) rtf_every(text, settings) - ok(f"{len(paras)} paragraph(s), {deepest} group(s) deep") + rtf_pictures(pictures, settings) + rtf_links(text, settings) + ok(f"{len(paras)} paragraph(s), {deepest} group(s) deep, {len(pictures)} picture(s)") def rtf_label(paras, settings): diff --git a/web/public/ar/formats/index.html b/web/public/ar/formats/index.html index 94620c2f..e8425e03 100644 --- a/web/public/ar/formats/index.html +++ b/web/public/ar/formats/index.html @@ -954,6 +954,31 @@

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

nesting 0 - 10000000 + + + links + 0 - 1000000 + + + + inline + 0 - 10000 + + + + inline_format + معرّف صيغة صورة، كما يسردها tfg formats + + + + inline_size + حجم مثل 2mb + + + + picture_data + binary, hex + svg width diff --git a/web/public/cs/formaty/index.html b/web/public/cs/formaty/index.html index a27b2eeb..9b27eaed 100644 --- a/web/public/cs/formaty/index.html +++ b/web/public/cs/formaty/index.html @@ -955,6 +955,31 @@

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

nesting 0 - 10000000 + + + links + 0 - 1000000 + + + + inline + 0 - 10000 + + + + inline_format + id obrázkového formátu tak, jak je vypisuje tfg formats + + + + inline_size + velikost, například 2mb + + + + picture_data + binary, hex + svg width diff --git a/web/public/de/formate/index.html b/web/public/de/formate/index.html index d4c18450..26c9b8bc 100644 --- a/web/public/de/formate/index.html +++ b/web/public/de/formate/index.html @@ -957,6 +957,31 @@

Einstellungen, die jedes Format kennt

nesting 0 - 10000000 + + + links + 0 - 1000000 + + + + inline + 0 - 10000 + + + + inline_format + die ID eines Bildformats, wie tfg formats sie auflistet + + + + inline_size + eine Größe wie 2mb + + + + picture_data + binary, hex + svg width diff --git a/web/public/es/formatos/index.html b/web/public/es/formatos/index.html index 864213c6..d0dc8394 100644 --- a/web/public/es/formatos/index.html +++ b/web/public/es/formatos/index.html @@ -958,6 +958,31 @@

Ajustes que admite cada formato

nesting 0 - 10000000 + + + links + 0 - 1000000 + + + + inline + 0 - 10000 + + + + inline_format + el id de un formato de imagen, tal como lo lista tfg formats + + + + inline_size + un tamaño como 2mb + + + + picture_data + binary, hex + svg width diff --git a/web/public/formats/index.html b/web/public/formats/index.html index 1112f510..5cc5f95b 100644 --- a/web/public/formats/index.html +++ b/web/public/formats/index.html @@ -956,6 +956,31 @@

Settings each format accepts

nesting 0 - 10000000 + + + links + 0 - 1000000 + + + + inline + 0 - 10000 + + + + inline_format + the id of a picture format, as tfg formats lists them + + + + inline_size + a size such as 2mb + + + + picture_data + binary, hex + svg width diff --git a/web/public/fr/formats/index.html b/web/public/fr/formats/index.html index 75631a1d..50e55b2e 100644 --- a/web/public/fr/formats/index.html +++ b/web/public/fr/formats/index.html @@ -958,6 +958,31 @@

Réglages que chaque format accepte

nesting 0 - 10000000 + + + links + 0 - 1000000 + + + + inline + 0 - 10000 + + + + inline_format + l'identifiant d'un format d'image, comme le liste tfg formats + + + + inline_size + une taille comme 2mb + + + + picture_data + binary, hex + svg width diff --git a/web/public/hi/formats/index.html b/web/public/hi/formats/index.html index b847911e..0964deee 100644 --- a/web/public/hi/formats/index.html +++ b/web/public/hi/formats/index.html @@ -956,6 +956,31 @@

हर फ़ॉर्मैट की स्वीकार की जा nesting 0 - 10000000 + + + links + 0 - 1000000 + + + + inline + 0 - 10000 + + + + inline_format + किसी चित्र फ़ॉर्मैट का id, जैसा tfg formats सूचीबद्ध करता है + + + + inline_size + 2mb जैसा आकार + + + + picture_data + binary, hex + svg width diff --git a/web/public/id/format/index.html b/web/public/id/format/index.html index bbd9ea0a..2aece4fe 100644 --- a/web/public/id/format/index.html +++ b/web/public/id/format/index.html @@ -956,6 +956,31 @@

Pengaturan yang diterima setiap format

nesting 0 - 10000000 + + + links + 0 - 1000000 + + + + inline + 0 - 10000 + + + + inline_format + id sebuah format gambar, seperti yang didaftar tfg formats + + + + inline_size + ukuran seperti 2mb + + + + picture_data + binary, hex + svg width diff --git a/web/public/it/formati/index.html b/web/public/it/formati/index.html index a038fbde..d1063e55 100644 --- a/web/public/it/formati/index.html +++ b/web/public/it/formati/index.html @@ -958,6 +958,31 @@

Impostazioni che ogni formato accetta

nesting 0 - 10000000 + + + links + 0 - 1000000 + + + + inline + 0 - 10000 + + + + inline_format + l'id di un formato di immagine, come lo elenca tfg formats + + + + inline_size + una dimensione come 2mb + + + + picture_data + binary, hex + svg width diff --git a/web/public/ja/formats/index.html b/web/public/ja/formats/index.html index c646d94a..dc034849 100644 --- a/web/public/ja/formats/index.html +++ b/web/public/ja/formats/index.html @@ -948,6 +948,31 @@

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

nesting 0 - 10000000 + + + links + 0 - 1000000 + + + + inline + 0 - 10000 + + + + inline_format + tfg formatsが一覧表示する画像形式のID + + + + inline_size + 2mbのようなサイズ + + + + picture_data + binary, hex + svg width diff --git a/web/public/ko/formats/index.html b/web/public/ko/formats/index.html index 8f2d5d28..c93434ce 100644 --- a/web/public/ko/formats/index.html +++ b/web/public/ko/formats/index.html @@ -949,6 +949,31 @@

각 형식이 받는 설정

nesting 0 - 10000000 + + + links + 0 - 1000000 + + + + inline + 0 - 10000 + + + + inline_format + tfg formats가 나열하는 이미지 형식의 id + + + + inline_size + 2mb와 같은 크기 + + + + picture_data + binary, hex + svg width diff --git a/web/public/nl/formaten/index.html b/web/public/nl/formaten/index.html index a5a894d3..de103a87 100644 --- a/web/public/nl/formaten/index.html +++ b/web/public/nl/formaten/index.html @@ -958,6 +958,31 @@

Instellingen die elk formaat accepteert

nesting 0 - 10000000 + + + links + 0 - 1000000 + + + + inline + 0 - 10000 + + + + inline_format + de id van een afbeeldingsformaat, zoals tfg formats ze opsomt + + + + inline_size + een grootte zoals 2mb + + + + picture_data + binary, hex + svg width diff --git a/web/public/pl/formaty/index.html b/web/public/pl/formaty/index.html index 173b7e09..2b3e7262 100644 --- a/web/public/pl/formaty/index.html +++ b/web/public/pl/formaty/index.html @@ -956,6 +956,31 @@

Ustawienia, które przyjmuje każdy format

nesting 0 - 10000000 + + + links + 0 - 1000000 + + + + inline + 0 - 10000 + + + + inline_format + identyfikator formatu obrazka, tak jak wypisuje go tfg formats + + + + inline_size + rozmiar, na przykład 2mb + + + + picture_data + binary, hex + svg width diff --git a/web/public/pt-br/formatos/index.html b/web/public/pt-br/formatos/index.html index ce4d2da7..e43d5885 100644 --- a/web/public/pt-br/formatos/index.html +++ b/web/public/pt-br/formatos/index.html @@ -957,6 +957,31 @@

Configurações que cada formato aceita

nesting 0 - 10000000 + + + links + 0 - 1000000 + + + + inline + 0 - 10000 + + + + inline_format + o id de um formato de imagem, como o tfg formats lista + + + + inline_size + um tamanho como 2mb + + + + picture_data + binary, hex + svg width diff --git a/web/public/ro/formate/index.html b/web/public/ro/formate/index.html index 6a2bde53..60d0661a 100644 --- a/web/public/ro/formate/index.html +++ b/web/public/ro/formate/index.html @@ -957,6 +957,31 @@

Setările pe care le acceptă fiecare format

nesting 0 - 10000000 + + + links + 0 - 1000000 + + + + inline + 0 - 10000 + + + + inline_format + id-ul unui format de imagine, așa cum îl listează tfg formats + + + + inline_size + o dimensiune precum 2mb + + + + picture_data + binary, hex + svg width diff --git a/web/public/ru/formats/index.html b/web/public/ru/formats/index.html index 9b253fe4..f5dfa665 100644 --- a/web/public/ru/formats/index.html +++ b/web/public/ru/formats/index.html @@ -956,6 +956,31 @@

Настройки, которые принимает каждый форм nesting 0 - 10000000 + + + links + 0 - 1000000 + + + + inline + 0 - 10000 + + + + inline_format + id формата изображения в том виде, как его выводит tfg formats + + + + inline_size + размер, например 2mb + + + + picture_data + binary, hex + svg width diff --git a/web/public/th/formats/index.html b/web/public/th/formats/index.html index 1e376d26..e7da4d32 100644 --- a/web/public/th/formats/index.html +++ b/web/public/th/formats/index.html @@ -954,6 +954,31 @@

การตั้งค่าที่แต่ละรูปแบบร nesting 0 - 10000000 + + + links + 0 - 1000000 + + + + inline + 0 - 10000 + + + + inline_format + รหัสของรูปแบบรูปภาพ ตามที่ tfg formats แสดง + + + + inline_size + ขนาด เช่น 2mb + + + + picture_data + binary, hex + svg width diff --git a/web/public/tr/bicimler/index.html b/web/public/tr/bicimler/index.html index 07338d2f..4d60dd82 100644 --- a/web/public/tr/bicimler/index.html +++ b/web/public/tr/bicimler/index.html @@ -956,6 +956,31 @@

Her biçimin kabul ettiği ayarlar

nesting 0 - 10000000 + + + links + 0 - 1000000 + + + + inline + 0 - 10000 + + + + inline_format + tfg formats'ın listelediği gibi bir görüntü biçiminin kimliği + + + + inline_size + 2mb gibi bir boyut + + + + picture_data + binary, hex + svg width diff --git a/web/public/uk/formats/index.html b/web/public/uk/formats/index.html index 81878041..9d2bb556 100644 --- a/web/public/uk/formats/index.html +++ b/web/public/uk/formats/index.html @@ -956,6 +956,31 @@

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

nesting 0 - 10000000 + + + links + 0 - 1000000 + + + + inline + 0 - 10000 + + + + inline_format + id формату зображення в тому вигляді, як його виводить tfg formats + + + + inline_size + розмір, наприклад 2mb + + + + picture_data + binary, hex + svg width diff --git a/web/public/vi/dinh-dang/index.html b/web/public/vi/dinh-dang/index.html index 61ece0db..a5fb644d 100644 --- a/web/public/vi/dinh-dang/index.html +++ b/web/public/vi/dinh-dang/index.html @@ -954,6 +954,31 @@

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

nesting 0 - 10000000 + + + links + 0 - 1000000 + + + + inline + 0 - 10000 + + + + inline_format + id của một định dạng hình ảnh, như tfg formats liệt kê + + + + inline_size + kích thước như 2mb + + + + picture_data + binary, hex + svg width diff --git a/web/public/zh-hans/formats/index.html b/web/public/zh-hans/formats/index.html index 0215cfad..fbcec961 100644 --- a/web/public/zh-hans/formats/index.html +++ b/web/public/zh-hans/formats/index.html @@ -948,6 +948,31 @@

每种格式接受的设置

nesting 0 - 10000000 + + + links + 0 - 1000000 + + + + inline + 0 - 10000 + + + + inline_format + 图片格式的 id,与 tfg formats 列出的一致 + + + + inline_size + 形如 2mb 的大小 + + + + picture_data + binary, hex + svg width diff --git a/web/public/zh-hant/formats/index.html b/web/public/zh-hant/formats/index.html index dec252a5..a22e2645 100644 --- a/web/public/zh-hant/formats/index.html +++ b/web/public/zh-hant/formats/index.html @@ -948,6 +948,31 @@

每種格式接受的設定

nesting 0 - 10000000 + + + links + 0 - 1000000 + + + + inline + 0 - 10000 + + + + inline_format + 圖片格式的 id,與 tfg formats 列出的相同 + + + + inline_size + 形如 2mb 的大小 + + + + picture_data + binary, hex + svg width From ceb88c5937d4282f5dc2f55a7d4cb0dd11767bb8 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Thu, 15 Oct 2026 20:56:15 +0200 Subject: [PATCH 2/2] rtf: say which keys the manifest adds, and make pandoc the subject of the Polish sentence Review of #192: the changelog read as if picture_data and links were the keys of the pictures, and the Polish detail of picture_data read as if the text lost the document. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- internal/gui/text/locale/registry/pl.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77e45a4f..1130a5e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,8 +82,8 @@ because it turns other people's test suites red. of them: LibreOffice turns the one without a scheme into a `file://` address, pandoc 3.1 and 3.7 drop every link with its text, and RichEdit returns each address in front of its text. The manifest records the - pictures in the keys a page and a message use, `picture_data` and - `links`. + pictures under the keys a page and a message use, and adds `picture_data` + and `links`. - **Films: the `webm` format.** A WebM video with an AV1 picture and no sound, at the exact size you ask for, like every other format. Its length is a setting of its own, independent of the size. The picture moves: every diff --git a/internal/gui/text/locale/registry/pl.json b/internal/gui/text/locale/registry/pl.json index 881fcbf5..a297d603 100644 --- a/internal/gui/text/locale/registry/pl.json +++ b/internal/gui/text/locale/registry/pl.json @@ -333,7 +333,7 @@ "Detail.format/rtf.inline_size": { "hash": "sha256-4024e523b3e9", "other": "Jak duży jest każdy obrazek. Zapisany w hex zajmuje w dokumencie dwa razy więcej bajtów, z końcem linii co 128 znaków. Zapisany binarnie zajmuje tyle, ile sam waży." }, "Detail.format/rtf.links": { "hash": "sha256-9e3ca1ff09e6", "other": "Ile odnośników niesie dokument, każdy jako pole HYPERLINK w osobnym akapicie, nad obrazkami i blokami. Przechodzą po kolei przez 17 rodzajów i potem się powtarzają - do zakładki w dokumencie, względny, do katalogu wyżej, od korzenia, bezwzględny z zapytaniem i fragmentem, bezwzględny z położeniem, http, bez schematu, z kodowaniem procentowym, domena międzynarodowa, port, mailto:, tel:, ftp:, nowe okno, podpowiedź i data: - a tekst każdego mówi, którym jest. Każdy adres leży w domenie zarezerwowanej na przykłady. LibreOffice zamienia odnośnik bez schematu na adres file:// i przepisuje względne od miejsca, w którym zapisuje dokument. pandoc 3.12 zostawia odnośnik do zakładki bez adresu, pandoc 3.1 i 3.7 gubią każdy odnośnik razem z tekstem, a RichEdit oddaje adres każdego odnośnika przed jego tekstem. LibreOffice czytał milion odnośników ponad 11 minut. Katalog wszystkich części ma jeden własny odnośnik, którego to ustawienie nie liczy." }, "Detail.format/rtf.nesting": { "hash": "sha256-e1d640adb186", "other": "W ilu grupach, jedna w drugiej, siedzi treść. 0 umieszcza ją wprost w dokumencie, a etykieta zostaje na zewnątrz, więc plik dalej mówi, czym jest. Żaden zmierzony czytnik nie zgubił treści przy 10 000 000 poziomów, ale LibreOffice zużył na nie 18,5 GB pamięci, a pandoc 14,5 GB, wobec około 2 GB każdy przy 1 000 000." }, - "Detail.format/rtf.picture_data": { "hash": "sha256-f91b5cd2e506", "other": "Jak zapisane są bajty każdego obrazka, co niczego nie zmienia, dopóki inline wynosi 0. hex zapisuje je jako tekst, dwa razy większy, tak jak zapisuje je LibreOffice. binarnie zapisuje je takie, jakie są, więc dokument przestaje być tekstem, a program, który liczy klamry albo czyta go jako tekst, gubi się. pandoc zmienia końce linii wewnątrz obrazka zapisanego binarnie, więc żaden png nie wychodzi cały, a tekst za obrazkiem może zgubić albo odrzucić cały dokument. LibreOffice, Tika i RichEdit czytają oba zapisy." }, + "Detail.format/rtf.picture_data": { "hash": "sha256-f91b5cd2e506", "other": "Jak zapisane są bajty każdego obrazka, co niczego nie zmienia, dopóki inline wynosi 0. hex zapisuje je jako tekst, dwa razy większy, tak jak zapisuje je LibreOffice. binarnie zapisuje je takie, jakie są, więc dokument przestaje być tekstem, a program, który liczy klamry albo czyta go jako tekst, gubi się. pandoc zmienia końce linii wewnątrz obrazka zapisanego binarnie, więc żaden png nie wychodzi cały, a pandoc może zgubić tekst za obrazkiem albo odrzucić cały dokument. LibreOffice, Tika i RichEdit czytają oba zapisy." }, "Detail.format/rtf.text_encoding": { "hash": "sha256-e6a5e55837f3", "other": "Jak zapisane są litery spoza ASCII. Gdy Znaki to ascii, nic to nie zmienia. unicode zapisuje każdą jako liczbę ze znakiem zapytania po niej dla czytnika, który nie zna liczb. oba dopisuje po liczbie literę w stronie kodowej jej fontu, dla japońskiego dwa bajty, tak jak zapisuje ją Word. strona kodowa zapisuje tylko bajty strony kodowej, a emoji dalej jako liczby, bo żadna strona kodowa ich nie ma. dosłowny zapisuje znak zapytania jako zwykły znak. LibreOffice, Tika i kontrolka RichEdit z Windows czytają wszystkie cztery. pandoc pokazuje tekst ze strony kodowej złymi literami i gubi znak po każdej literze zapisanej dosłownie, a pandoc 3.1 i 3.7 gubią emoji we wszystkich czterech. striprtf oddaje emoji jako dwie połówki, których Python nie zapisze w UTF-8." }, "Detail.format/svg.height": { "hash": "sha256-093ec883d633", "other": "Jaką wysokość deklaruje rysunek. Etykieta biegnie wzdłuż dolnej krawędzi, więc rysunek niższy od tego pasa nie ma widocznej etykiety." }, "Detail.format/svg.width": { "hash": "sha256-5dc327958f8d", "other": "Jaką szerokość deklaruje rysunek. Nic nie jest tu rysowane w pikselach, więc duża liczba kosztuje kilka bajtów w pliku i bardzo dużo pamięci w tym, co go otworzy." },