issue 1440: file the scan as a one-page PDF, written whole and renamed, and list the filed scans

This commit is contained in:
2026-10-20 20:11:39 +02:00
parent 26ae4d2fd8
commit ae47a6cc10
3 changed files with 369 additions and 0 deletions
+124
View File
@@ -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
}
+194
View File
@@ -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)
}
}
+51
View File
@@ -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
}