issue 1440: file the scan as a one-page PDF, written whole and renamed, and list the filed scans
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user