diff --git a/modules/scan/cmd/scan/file.go b/modules/scan/cmd/scan/file.go new file mode 100644 index 00000000..44de023a --- /dev/null +++ b/modules/scan/cmd/scan/file.go @@ -0,0 +1,124 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// FileMode is the mode a created scan folder gets and ScanMode the mode of a filed scan. +const ( + FolderMode os.FileMode = 0o755 + ScanMode os.FileMode = 0o644 +) + +// File writes the PDF into the folder as scan-YYYY-MM-DD-HHMMSS.pdf (local time): whole beside its place as +// .scan-*.tmp and then renamed, so nothing ever sees half a scan. A name taken already gets -2, -3, and a file +// that is there is never overwritten. The folder is created when it is not there. Nothing in it that this +// writes is touched but what it makes (1440/AC5, AC11). +func File(folder string, pdf []byte, at time.Time) (string, error) { + if !filepath.IsAbs(folder) { + return "", fmt.Errorf("scan-folder %q is not an absolute path", folder) + } + if _, err := os.Stat(folder); os.IsNotExist(err) { + if err := os.MkdirAll(folder, FolderMode); err != nil { + return "", fmt.Errorf("the scan folder cannot be made: %w", err) + } + // The umask may have narrowed it; a folder this made is 0755 whatever the umask. + if err := os.Chmod(folder, FolderMode); err != nil { + return "", fmt.Errorf("the scan folder cannot be made: %w", err) + } + } + tmp, err := os.CreateTemp(folder, ".scan-*.tmp") + if err != nil { + return "", fmt.Errorf("the scan cannot be written in %s: %w", folder, err) + } + done := false + defer func() { + if !done { + tmp.Close() + os.Remove(tmp.Name()) + } + }() + if _, err := tmp.Write(pdf); err != nil { + return "", fmt.Errorf("the scan cannot be written: %w", err) + } + if err := tmp.Sync(); err != nil { + return "", fmt.Errorf("the scan cannot be written: %w", err) + } + if err := tmp.Close(); err != nil { + return "", fmt.Errorf("the scan cannot be written: %w", err) + } + if err := os.Chmod(tmp.Name(), ScanMode); err != nil { + return "", err + } + stem := "scan-" + at.Local().Format("2006-01-02-150405") + for n := 1; n < 10000; n++ { + name := stem + ".pdf" + if n > 1 { + name = fmt.Sprintf("%s-%d.pdf", stem, n) + } + final := filepath.Join(folder, name) + // The name is taken by creating it exclusively; the finished file is then renamed over that empty + // one, so a scan never replaces a file that was there. + f, err := os.OpenFile(final, os.O_CREATE|os.O_EXCL|os.O_WRONLY, ScanMode) + if os.IsExist(err) { + continue + } + if err != nil { + return "", fmt.Errorf("the scan cannot be named in %s: %w", folder, err) + } + f.Close() + if err := os.Rename(tmp.Name(), final); err != nil { + os.Remove(final) + return "", fmt.Errorf("the scan cannot be put in place: %w", err) + } + done = true + return final, nil + } + return "", fmt.Errorf("no free scan name for %s in %s", stem, folder) +} + +// Entry is one filed scan, as scan_list says it. +type Entry struct { + Path string `json:"path"` + Bytes int64 `json:"bytes"` + Modified time.Time `json:"modified"` +} + +// List is the newest PDFs named scan-*.pdf in the folder, newest first, at most limit. A folder that is not +// there yet is an empty list. +func List(folder string, limit int) ([]Entry, error) { + entries, err := os.ReadDir(folder) + if os.IsNotExist(err) { + return []Entry{}, nil + } + if err != nil { + return nil, fmt.Errorf("the scan folder cannot be read: %w", err) + } + out := []Entry{} + for _, e := range entries { + n := e.Name() + if e.IsDir() || !strings.HasPrefix(n, "scan-") || !strings.HasSuffix(n, ".pdf") { + continue + } + info, err := e.Info() + if err != nil || !info.Mode().IsRegular() { + continue + } + out = append(out, Entry{Path: filepath.Join(folder, n), Bytes: info.Size(), Modified: info.ModTime()}) + } + sort.SliceStable(out, func(i, j int) bool { + if !out[i].Modified.Equal(out[j].Modified) { + return out[i].Modified.After(out[j].Modified) + } + return out[i].Path > out[j].Path + }) + if limit > 0 && len(out) > limit { + out = out[:limit] + } + return out, nil +} diff --git a/modules/scan/cmd/scan/file_test.go b/modules/scan/cmd/scan/file_test.go new file mode 100644 index 00000000..c92a8ca2 --- /dev/null +++ b/modules/scan/cmd/scan/file_test.go @@ -0,0 +1,194 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + "time" +) + +var scanName = regexp.MustCompile(`^scan-\d{4}-\d{2}-\d{2}-\d{6}(-\d+)?\.pdf$`) + +// 1440/AC5: a valid one-page PDF holding the JPEG as DCTDecode, at the image's real size. +func TestPDFHoldsTheJPEGAtItsRealSize(t *testing.T) { + jpg := fixture(t, "scan.jpg") + pdf, err := PDF(jpg, 200) + if err != nil { + t.Fatal(err) + } + s := string(pdf) + if !strings.HasPrefix(s, "%PDF-1.4") || !strings.HasSuffix(s, "%%EOF\n") { + t.Fatalf("not a PDF: starts %q ends %q", s[:8], s[len(s)-6:]) + } + if !bytes.Contains(pdf, jpg) { + t.Error("the JPEG is not embedded as it is") + } + for _, want := range []string{"/Filter /DCTDecode", "/Width 1696", "/Height 2203", "/ColorSpace /DeviceRGB", "/Count 1"} { + if !strings.Contains(s, want) { + t.Errorf("the PDF lacks %s", want) + } + } + // 1696 x 2203 px at 200 dpi is 610.56 x 793.08 pt, never the 300 dpi that was asked. + if !strings.Contains(s, "/MediaBox [0 0 610.56 793.08]") { + t.Errorf("page size wrong:\n%s", regexp.MustCompile(`/MediaBox[^\]]*\]`).FindString(s)) + } + if n := strings.Count(s, "/Type /Page "); n != 1 { + t.Errorf("%d pages", n) + } + // Every xref entry points at its object. + at := strings.LastIndex(s, "startxref\n") + xref, _ := strconv.Atoi(strings.Fields(s[at+len("startxref\n"):])[0]) + lines := strings.Split(s[xref:], "\n") + for i := 3; i <= 7 && i < len(lines); i++ { + off, err := strconv.Atoi(lines[i][:10]) + if err != nil || !strings.HasPrefix(s[off:], fmt.Sprintf("%d 0 obj", i-2)) { + t.Errorf("xref entry %d (%q) does not point at object %d", i-2, lines[i], i-2) + } + } +} + +// 1440/AC2: the page size follows the density it is given. +func TestPDFPageSizeFollowsTheDensity(t *testing.T) { + jpg := fixture(t, "scan.jpg") + a, _ := PDF(jpg, 200) + b, _ := PDF(jpg, 300) + if bytes.Equal(a, b) { + t.Fatal("200 and 300 dpi give the same page") + } + if !strings.Contains(string(b), "/MediaBox [0 0 407.04 528.72]") { + t.Errorf("300 dpi page wrong") + } + if _, err := PDF(jpg, 0); err == nil { + t.Error("0 dpi accepted") + } +} + +// 1440/AC5: exactly one file, named by local time, no temporary left, its bytes the PDF. +func TestFileWritesOneNamedFileAndLeavesNoTemporary(t *testing.T) { + folder := t.TempDir() + at := time.Date(2026, 10, 20, 19, 5, 9, 0, time.Local) + path, err := File(folder, []byte("%PDF-x"), at) + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(folder, "scan-2026-10-20-190509.pdf"); path != want { + t.Fatalf("path = %s, want %s", path, want) + } + entries, _ := os.ReadDir(folder) + if len(entries) != 1 || !scanName.MatchString(entries[0].Name()) { + t.Fatalf("folder holds %v, want the one scan", entries) + } + if got, _ := os.ReadFile(path); string(got) != "%PDF-x" { + t.Fatalf("file holds %q", got) + } +} + +// 1440/AC5: a name taken gets -2, -3; what was there is never overwritten. +func TestFileNeverOverwritesAndSuffixes(t *testing.T) { + folder := t.TempDir() + at := time.Date(2026, 10, 20, 19, 5, 9, 0, time.Local) + first, _ := File(folder, []byte("one"), at) + second, err := File(folder, []byte("two"), at) + if err != nil { + t.Fatal(err) + } + third, _ := File(folder, []byte("three"), at) + for p, want := range map[string]string{ + first: "one", second: "two", third: "three", + } { + if got, _ := os.ReadFile(p); string(got) != want { + t.Errorf("%s holds %q, want %q", p, got, want) + } + } + if filepath.Base(second) != "scan-2026-10-20-190509-2.pdf" || filepath.Base(third) != "scan-2026-10-20-190509-3.pdf" { + t.Errorf("names %s and %s", filepath.Base(second), filepath.Base(third)) + } +} + +// 1440/AC11: a folder that is not there is made, 0755, and the scan is filed in it. +func TestFileMakesAMissingFolder(t *testing.T) { + folder := filepath.Join(t.TempDir(), "a", "scans") + path, err := File(folder, []byte("x"), time.Now()) + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(folder) + if err != nil || info.Mode().Perm() != 0o755 { + t.Fatalf("folder: %v, mode %v, want 0755", err, info) + } + if _, err := os.Stat(path); err != nil { + t.Fatal(err) + } +} + +// 1440/AC11: a file in the folder that the module did not write is left as it was by every tool. +func TestFileLeavesForeignFilesAlone(t *testing.T) { + folder := t.TempDir() + old := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + foreign := map[string]string{"notes.txt": "mine", ".scan-theirs.tmp": "half", "scan-2020-01-01-000000.pdf": "someone else's"} + for n, c := range foreign { + p := filepath.Join(folder, n) + os.WriteFile(p, []byte(c), 0o600) + os.Chtimes(p, old, old) + } + if _, err := File(folder, []byte("x"), time.Now()); err != nil { + t.Fatal(err) + } + if _, err := List(folder, 10); err != nil { + t.Fatal(err) + } + for n, c := range foreign { + p := filepath.Join(folder, n) + got, err := os.ReadFile(p) + info, _ := os.Stat(p) + if err != nil || string(got) != c || !info.ModTime().Equal(old) || info.Mode().Perm() != 0o600 { + t.Errorf("%s changed: %q %v", n, got, err) + } + } +} + +// 1440/AC7: a relative folder writes nothing. +func TestFileRefusesARelativeFolder(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + if _, err := File("scans", []byte("x"), time.Now()); err == nil { + t.Fatal("a relative folder was accepted") + } + if entries, _ := os.ReadDir(dir); len(entries) != 0 { + t.Fatalf("wrote %v", entries) + } +} + +// 1440/AC8: scan_list: newest first, only scan PDFs, limited. +func TestListIsNewestFirstAndLimited(t *testing.T) { + folder := t.TempDir() + base := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + for i, n := range []string{"scan-a.pdf", "scan-b.pdf", "scan-c.pdf", "other.pdf", "scan-d.txt"} { + p := filepath.Join(folder, n) + os.WriteFile(p, []byte("x"), 0o644) + at := base.Add(time.Duration(i) * time.Hour) + os.Chtimes(p, at, at) + } + got, err := List(folder, 20) + if err != nil { + t.Fatal(err) + } + names := []string{} + for _, e := range got { + names = append(names, filepath.Base(e.Path)) + } + if strings.Join(names, ",") != "scan-c.pdf,scan-b.pdf,scan-a.pdf" { + t.Fatalf("list = %v", names) + } + if got, _ = List(folder, 2); len(got) != 2 || filepath.Base(got[0].Path) != "scan-c.pdf" { + t.Fatalf("limit 2 = %v", got) + } + if got, err = List(filepath.Join(folder, "none"), 5); err != nil || len(got) != 0 { + t.Fatalf("missing folder = %v, %v", got, err) + } +} diff --git a/modules/scan/cmd/scan/pdf.go b/modules/scan/cmd/scan/pdf.go new file mode 100644 index 00000000..f394d924 --- /dev/null +++ b/modules/scan/cmd/scan/pdf.go @@ -0,0 +1,51 @@ +package main + +import ( + "bytes" + "fmt" + "image/color" + "image/jpeg" +) + +// PDF writes a one-page PDF holding the JPEG as it is (DCTDecode), the page as large as the image really is: +// pixels over dpi, in points (72 to the inch). +func PDF(jpegBytes []byte, dpi int) ([]byte, error) { + if dpi <= 0 { + return nil, fmt.Errorf("resolution %d dpi is no resolution", dpi) + } + cfg, err := jpeg.DecodeConfig(bytes.NewReader(jpegBytes)) + if err != nil { + return nil, fmt.Errorf("the image cannot be read: %w", err) + } + space := "DeviceRGB" + if cfg.ColorModel == color.GrayModel { + space = "DeviceGray" + } + wpt := float64(cfg.Width) / float64(dpi) * 72 + hpt := float64(cfg.Height) / float64(dpi) * 72 + content := fmt.Sprintf("q %.2f 0 0 %.2f 0 0 cm /Im0 Do Q\n", wpt, hpt) + + var b bytes.Buffer + var offsets []int + obj := func(body string) { + offsets = append(offsets, b.Len()) + fmt.Fprintf(&b, "%d 0 obj\n%s\nendobj\n", len(offsets), body) + } + b.WriteString("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n") + obj("<< /Type /Catalog /Pages 2 0 R >>") + obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + obj(fmt.Sprintf("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %.2f %.2f] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>", wpt, hpt)) + offsets = append(offsets, b.Len()) + fmt.Fprintf(&b, "4 0 obj\n<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /%s /BitsPerComponent 8 /Filter /DCTDecode /Length %d >>\nstream\n", + cfg.Width, cfg.Height, space, len(jpegBytes)) + b.Write(jpegBytes) + b.WriteString("\nendstream\nendobj\n") + obj(fmt.Sprintf("<< /Length %d >>\nstream\n%sendstream", len(content), content)) + xref := b.Len() + fmt.Fprintf(&b, "xref\n0 %d\n0000000000 65535 f \n", len(offsets)+1) + for _, o := range offsets { + fmt.Fprintf(&b, "%010d 00000 n \n", o) + } + fmt.Fprintf(&b, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets)+1, xref) + return b.Bytes(), nil +}