Add PDF/HEIC/image upload support and improve receipt extraction

- Add backend image conversion endpoint (POST /api/convert-image) supporting
  PDF, HEIC, PNG, WebP via Pillow, PyMuPDF, and pillow-heif
- Add separate "Upload file" button in UI while keeping camera-first behavior
  for the photo area and + button
- Improve Haiku extraction prompt for hotel receipts (parenthesized total)
- Increase max image resolution from 1024px to 2048px for better OCR accuracy
- Add libheif-dev system dependency in Dockerfile

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 01:20:23 -06:00
parent fde6fcb724
commit a5c6996219
4 changed files with 113 additions and 4 deletions

View File

@@ -542,6 +542,8 @@
<span>Tap to take photo</span>
</div>
<input type="file" id="photo-input" accept="image/*" capture="environment" hidden>
<input type="file" id="file-input" accept="image/*,.pdf,.heic,.heif,application/pdf" hidden>
<button type="button" id="upload-file-btn" style="margin-top:8px;width:100%;padding:10px;border:1.5px dashed #aaa;border-radius:8px;background:#fafafa;color:#666;font-size:0.9rem;cursor:pointer;">Upload file (PDF, HEIC, image)</button>
<div class="extract-status" id="extract-status"></div>
</div>
@@ -695,6 +697,8 @@
const modalTitle = document.getElementById("modal-title");
const photoArea = document.getElementById("photo-area");
const photoInput = document.getElementById("photo-input");
const fileInput = document.getElementById("file-input");
const uploadFileBtn = document.getElementById("upload-file-btn");
const dateInput = document.getElementById("date-input");
const amountInput = document.getElementById("amount-input");
const categoryInput = document.getElementById("category-input");
@@ -866,6 +870,7 @@
function closeModal() {
overlay.classList.remove("open");
photoInput.value = "";
fileInput.value = "";
}
function updatePhotoArea() {
@@ -876,7 +881,7 @@
photoArea.classList.remove("has-photo");
photoArea.innerHTML = `
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/><circle cx="12" cy="13" r="4"/></svg>
<span>Tap to take photo</span>`;
<span>Tap to add photo or file</span>`;
}
}
@@ -1055,6 +1060,7 @@
});
photoArea.addEventListener("click", () => photoInput.click());
uploadFileBtn.addEventListener("click", () => fileInput.click());
// --- Receipt extraction via Claude ---
const extractStatus = document.getElementById("extract-status");
@@ -1290,11 +1296,50 @@
}, "image/jpeg", 0.85);
});
async function convertViaBackend(file) {
const buf = await file.arrayBuffer();
const r = await fetch("/api/convert-image", {
method: "POST",
headers: { "Content-Type": file.type || "application/octet-stream" },
body: buf
});
if (!r.ok) throw new Error("Conversion failed");
return await r.blob();
}
function needsBackendConversion(file) {
const type = (file.type || "").toLowerCase();
const name = (file.name || "").toLowerCase();
if (type === "application/pdf" || name.endsWith(".pdf")) return true;
if (type === "image/heic" || type === "image/heif" || name.endsWith(".heic") || name.endsWith(".heif")) return true;
return false;
}
photoInput.addEventListener("change", async e => {
const file = e.target.files[0];
if (!file) return;
const resized = await resizeImage(file, 1024);
openCropOverlay(resized);
const blob = await resizeImage(file, 2048);
openCropOverlay(blob);
});
fileInput.addEventListener("change", async e => {
const file = e.target.files[0];
if (!file) return;
let blob;
if (needsBackendConversion(file)) {
try {
extractStatus.textContent = "Converting file...";
blob = await convertViaBackend(file);
} catch (err) {
extractStatus.textContent = "Conversion failed";
return;
} finally {
if (extractStatus.textContent === "Converting file...") extractStatus.textContent = "";
}
} else {
blob = await resizeImage(file, 2048);
}
openCropOverlay(blob);
});
btnSave.addEventListener("click", async () => {