Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,15 @@ because it turns other people's test suites red.
the unexpected end. When the cut is shorter than the end of the archive -
1% of 64 KiB - it falls there and every file is kept, and the manifest says
`in: end`. Files nothing cuts are unchanged.
- **`truncate` cuts the pages of a PDF by the percentage you set.**
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
A cut used to take only padding, leaving every page. Now
`--damage truncate:missing=50` removes half of the document and its closing
index, as an interrupted download does. pdf.js (Firefox), PDFium
(Chrome, Edge), Poppler and the Windows PDF viewer refuse it, and MuPDF and
Ghostscript show the part that arrived. Two cuts keep every page, and the
manifest says so: a few percent of a small PDF, falling in the index, and
one keeping 10 billion bytes or more, which the index cannot reach. Files
nothing cuts are unchanged.

- **tfg is on npm, for tests in Node.**
`npm install --save-dev testing-files-generator` gives Playwright, Vitest
Expand Down
62 changes: 62 additions & 0 deletions internal/format/pdf/cut.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package pdf

// The layout of a document a damage will cut: its padding split around the
// objects.

import "github.com/donislawdev/TestingFilesGenerator/internal/format"

// largestOffset is the largest offset a cross reference entry can carry. ISO
// 32000-1 section 7.5.4 writes each one in ten digits.
const largestOffset = 9_999_999_999

// splitForACut is how much of the padding m has behind its trailer goes in
// front of its objects instead, for a document a damage will cut after its
// first cutAt bytes of whole, so that the cut keeps the share of the objects it
// keeps of the whole file (format.Request.CutAt). It returns the padding in
// front and the padding left behind, and none in front for a document nothing
// cuts.
//
// With all of the padding behind the trailer, as a document nothing cuts has
// it, a large document cut at any share kept every object and lost only
// padding. A comment may stand between the header lines and the first object as
// well, and the readers measured take it there as they take it behind the
// trailer (docs/TRUNCATE-2026-11-11.md section 12).
//
// The padding in front moves every object, so the offset startxref carries
// grows with it and can gain a digit, which the padding behind gives up. Each
// side is nothing or a comment of at least minComment bytes. Where the sides
// cannot land exactly the front takes a byte more, which cuts a byte more of
// the objects - the "at least" missing promises - or, where a digit gained
// leaves that no room, a byte less. Where the end is longer than the part the
// cut takes, all of the padding goes in front, and the cut falls in the end.
// An object can start no later than largestOffset, so in a document of more
// than about 9.3 GiB the front stops there and the cut keeps more of the
// objects than the share.
func splitForACut(m memo, whole, cutAt int64) (front, back int64) {
if m.padLen == 0 || cutAt <= 0 || cutAt >= whole {
return 0, m.padLen
}
want := format.PaddingInFront(m.at.objects, m.at.xref-m.at.objects, cutAt, whole)
if want <= 0 {
return 0, m.padLen
}
// padding is all of the padding with front of it in front: the closing
// lines carry the offset front moves.
padding := func(front int64) int64 {
return whole - int64(len(m.prefix)) - int64(len(closing(m.at.xref+front)))
}
fits := func(front int64) bool {
back := padding(front) - front
return front >= minComment && m.at.xref+front <= largestOffset && (back == 0 || back >= minComment)
}
front = min(max(want, minComment), largestOffset-m.at.xref)
if padding(front) < front {
front = padding(front)
}
for _, f := range []int64{front, front + 1, front - 1} {
if fits(f) {
return f, padding(f) - f
}
}
return 0, m.padLen
}
22 changes: 16 additions & 6 deletions internal/format/pdf/document.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,17 @@ type places struct {
objects, xref int64
}

// document builds everything up to the trailer, and the closing lines.
// document builds everything up to the trailer, and the closing lines, of a
// document with front bytes of padding between its header lines and its first
// object (splitForACut).
//
// The two are returned apart because the padding goes between them. The
// closing lines carry the offset of the cross reference table, which sits in
// the first part and therefore does not move.
func document(m memo) (prefix, suffix []byte, at places) {
// the first part and therefore does not move. The front padding is not in the
// first part - the writer puts it in at at.objects - but every offset counts
// it. The cross reference table writes each offset in ten digits, so the first
// part is the same length whatever front is.
func document(m memo, front int64) (prefix, suffix []byte, at places) {
var body bytes.Buffer
// The version is the one asked for and nothing else follows from it: the
// file uses nothing newer than 1.4, so every version offered here is true
Expand Down Expand Up @@ -73,10 +78,15 @@ func document(m memo) (prefix, suffix []byte, at places) {
fmt.Fprintf(&body, "xref\n0 %d\n", len(objects)+1)
body.WriteString("0000000000 65535 f \n")
for _, off := range offsets {
fmt.Fprintf(&body, "%010d 00000 n \n", off)
fmt.Fprintf(&body, "%010d 00000 n \n", int64(off)+front)
}
fmt.Fprintf(&body, "trailer\n<</Size %d/Root 1 0 R/Info 4 0 R>>\n", len(objects)+1)

suffix = []byte(fmt.Sprintf("startxref\n%d\n%%%%EOF\n", xrefPos))
return body.Bytes(), suffix, at
return body.Bytes(), closing(at.xref + front), at
}

// closing is startxref, the offset of the cross reference table it points at,
// and the end of file marker.
func closing(xref int64) []byte {
return []byte(fmt.Sprintf("startxref\n%d\n%%%%EOF\n", xref))
}
2 changes: 1 addition & 1 deletion internal/format/pdf/minimum.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,6 @@ func minimumBytes() int64 {
if err != nil {
panic("pdf: the default settings are refused: " + err.Error())
}
prefix, suffix, _ := document(memo{opts: opts})
prefix, suffix, _ := document(memo{opts: opts}, 0)
return int64(len(prefix) + len(suffix))
}
3 changes: 2 additions & 1 deletion internal/format/pdf/padding.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ const (
)

// The padding channel is a comment block placed after the trailer and before
// startxref.
// startxref. A document a damage will cut has part of it in front of the
// objects instead (splitForACut).
//
// The obvious place, and the one this project's own notes assumed, is between
// startxref and %%EOF. Measured, that is wrong twice over.
Expand Down
42 changes: 29 additions & 13 deletions internal/format/pdf/pdf.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,12 @@ type memo struct {
// not depend on the padding, because the offset it carries points at the
// cross reference table, which sits before the padding.
suffix []byte
// at is where the objects and the cross reference table start.
at places
padLen int64
// at is where the objects and the cross reference table start in prefix.
at places
// front is the padding between the header lines and the first object,
// which only a document a damage will cut has (splitForACut), and padLen
// the padding behind the trailer.
front, padLen int64
}

func (generator) Plan(r format.Request) (format.Plan, error) {
Expand All @@ -68,7 +71,7 @@ func (generator) Plan(r format.Request) (format.Plan, error) {
}

m := memo{opts: opts, seed: r.Seed, label: label}
m.prefix, m.suffix, m.at = document(m)
m.prefix, m.suffix, m.at = document(m, 0)

// One number answers both questions: how much padding this file needs, and
// what is too small to make. Every line of body text is the same width
Expand Down Expand Up @@ -108,6 +111,9 @@ func (generator) Plan(r format.Request) (format.Plan, error) {
default:
m.padLen = r.Bytes - bare
}
if m.front, m.padLen = splitForACut(m, r.Bytes, r.CutAt); m.front > 0 {
m.prefix, m.suffix, _ = document(m, m.front)
}

p.Memo = m
return p, nil
Expand Down Expand Up @@ -170,18 +176,28 @@ func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error {
default:
}

// The map, all of it known from the plan: the header lines, the objects -
// the pages and what they draw with - the cross reference table and the
// The map, all of it known from the plan: the header lines, the comment
// in front of the objects a document a damage cuts has, the objects - the
// pages and what they draw with - the cross reference table and the
// trailer, the comment that makes up the size, and startxref with the
// closing marker. The padding stands between two parts of the end.
head, end := int64(len(m.prefix)), int64(len(m.prefix))+m.padLen
// closing marker. The padding behind stands between two parts of the end.
objects := m.at.objects + m.front
table := m.at.xref + m.front
behind := int64(len(m.prefix)) + m.front
format.Mark(w, format.RoleStructure, 0, m.at.objects)
format.Mark(w, format.RoleContent, m.at.objects, m.at.xref-m.at.objects)
format.Mark(w, format.RoleEnd, m.at.xref, head-m.at.xref)
format.Mark(w, format.RolePadding, head, m.padLen)
format.Mark(w, format.RoleEnd, end, int64(len(m.suffix)))
format.Mark(w, format.RolePadding, m.at.objects, m.front)
format.Mark(w, format.RoleContent, objects, table-objects)
format.Mark(w, format.RoleEnd, table, behind-table)
format.Mark(w, format.RolePadding, behind, m.padLen)
format.Mark(w, format.RoleEnd, behind+m.padLen, int64(len(m.suffix)))

if _, err := w.Write(m.prefix); err != nil {
if _, err := w.Write(m.prefix[:m.at.objects]); err != nil {
return err
}
if err := writeComment(ctx, w, m.seed, m.front); err != nil {
return err
}
if _, err := w.Write(m.prefix[m.at.objects:]); err != nil {
return err
}
if err := writeComment(ctx, w, m.seed, m.padLen); err != nil {
Expand Down
18 changes: 12 additions & 6 deletions internal/guard/cutmanifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package guard
import (
"fmt"
"os"
"slices"
"strconv"
"strings"
"testing"
Expand Down Expand Up @@ -36,8 +37,8 @@ type cutCase struct {
// the content is left, and gets a note when the cut missed the content.
//
// The shapes a cut takes (docs/TRUNCATE-2026-11-11.md sections 2, 7 and 8).
// A PDF of 1 MiB is nearly all padding after its pages, so 90% missing still
// leaves every page. A PNG splits its padding around the picture
// An MP4 of 1 MiB is nearly all padding after its video, so 90% missing still
// leaves all of it. A PNG splits its padding around the picture
// for a cut, so the cut takes the share asked for, and so does a tar.gz. A
// text is its words, so the cut falls in them. Two cuts in a row end the file where the second one does, and
// the cut is said of that one. A damage that cuts nothing has no cut to report.
Expand All @@ -46,8 +47,9 @@ type cutCase struct {
// one that claims content it does not have.
func TestACutFileSaysWhereTheCutFellAndWhatIsLeft(t *testing.T) {
cases := []cutCase{
// A GIF was this case until it split its padding for the cut.
{name: "behind", format: "pdf", size: 1 << 20, missing: "90", in: "padding", kept: "all", note: "after the content"},
// A GIF and then a PDF were this case until each split its padding
// for the cut.
{name: "behind", format: "mp4", size: 1 << 20, missing: "90", in: "padding", kept: "all", note: "after the content"},
{name: "picture", format: "png", size: 1 << 20, missing: "90", in: "content", kept: "share"},
// 4 KiB on disk is 40 KiB whole. It was the case of a cut in the padding
// in front of the content, with an icon and a JPEG before it, until it
Expand Down Expand Up @@ -178,7 +180,7 @@ func TestTheRecordOfACutRunIsNeverLongerThanItsForecast(t *testing.T) {
}
opt := engine.Options{OutDir: t.TempDir(), Seed: 7, Command: "test"}
planned, err := engine.Plan([]engine.Target{
cut("picture", "png", 1<<20, "90"), cut("behind", "pdf", 1<<20, "90"), cut("text", "txt", 1000, "50"),
cut("picture", "png", 1<<20, "90"), cut("behind", "mp4", 1<<20, "90"), cut("text", "txt", 1000, "50"),
}, opt)
if err != nil {
t.Fatalf("planning: %v", err)
Expand Down Expand Up @@ -262,7 +264,11 @@ func TestACutAtTheSmallestSizeSaysHowLongTheContentWas(t *testing.T) {
}
cut := *m.Damage[0].Cut
whole := wholeMap(t, plannedOf[m.TargetID])
if _, zipped := endClosesTheFile[m.TargetID]; zipped && (paddingInFront(whole) || cut.In != string(format.RoleContent)) {
// Named rather than read off endClosesTheFile, which a PDF and a
// tar.gz joined: a PDF at its smallest has room for its comment in
// front, as it should.
zipped := slices.Contains([]string{"zip", "docx", "xlsx", "pptx"}, m.TargetID)
if zipped && (paddingInFront(whole) || cut.In != string(format.RoleContent)) {
t.Errorf("%s: at %d B the cut fell in the %s with padding in front of the content: %v - the layout too short for a padding part in front is not reached",
m.TargetID, m.Bytes, cut.In, paddingInFront(whole))
}
Expand Down
4 changes: 2 additions & 2 deletions internal/guard/cutshare_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ var notYetCutAtTheShare = map[string]notYet{
"jxl": {paddingBehind, 64 << 10, 50},
"mp4": {paddingBehind, 64 << 10, 50},
"webm": {paddingBehind, 64 << 10, 50},
"pdf": {paddingBehind, 64 << 10, 50},
// Both take the share at 64 KiB and above. At 4 KiB what stands behind the
// picture is more than a small share, so a cut of 1% keeps all of it.
"webp": {smallBehind, 4 << 10, 1},
Expand All @@ -51,7 +50,7 @@ var notYetCutAtTheShare = map[string]notYet{
// guard for it, and it stays red until the format splits its padding for
// format.Request.CutAt. A format that leaves the ratchet leaves this list too,
// so it cannot go back.
var listedBeforeTheRule = []string{"avif", "jxl", "mp4", "webm", "pdf", "webp", "tiff"}
var listedBeforeTheRule = []string{"avif", "jxl", "mp4", "webm", "webp", "tiff"}

// listedAfterTheRule is a problem for every format notYetAt names that
// listedBeforeTheRule does not.
Expand Down Expand Up @@ -211,6 +210,7 @@ var endClosesTheFile = map[string]string{
"xlsx": "a ZIP package, laid out as APPNOTE 4.3.6 lays out a ZIP: the central directory after every part",
"pptx": "a ZIP package, laid out as APPNOTE 4.3.6 lays out a ZIP: the central directory after every part",
"targz": "POSIX tar puts the end of archive marker after every entry, and RFC 1952 closes a gzip member with its trailer",
"pdf": "ISO 32000-1 section 7.5 puts the cross reference table and the trailer after the body, and the end of file marker last",
}

// onlyTheEndBehind is whether nothing but the end stands behind the content of
Expand Down
6 changes: 6 additions & 0 deletions internal/guard/mutationcoverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ var provenByProbe = map[string]string{
"fixed codes, must inflate to its bytes and read as those runs - its reach check, every frame of the marker met - and red on " +
"2026-11-13 for a frame a byte short (the plan refuses at 65541 B) and for an empty block's BTYPE bit one place off " +
"(compress/flate refuses at 65537 B), where the first passed the share and the map guards.",
"TestAPDFLaidOutForACutComesToEverySizeAndItsOffsetsLand": "its own canaries - a table that sends one object a byte off " +
"must be refused, and the layout of a document nothing cuts must fail a cut at its half - its reach check, every " +
"layout of the split met, red on 2026-11-13 with nothing in front of a document under 400 B of padding (398 sizes, " +
"where the guard before the review passed), and red without the candidate of a " +
"byte more in front (that layout never met), with the length of startxref taken before the padding moved it (10212 B " +
"came to 10213 B) and without the stop at ten digits (20 GiB, objects ending at 19327353153).",
"TestATarGzCutNearItsSmallestComesToEverySize": "its reach check - the frame met, and without a label the size after its floor " +
"that keeps the layout of an archive nothing cuts, which with a label is due never - and red on 2026-11-13 without the " +
"case of one byte over the floor (an internal defect at 9757 B) and with that case taken for a label too (label true).",
Expand Down
Loading
Loading