diff --git a/src/authoring_service/templates.py b/src/authoring_service/templates.py
index 1331ccd..523f749 100755
--- a/src/authoring_service/templates.py
+++ b/src/authoring_service/templates.py
@@ -288,7 +288,7 @@ APP_HTML = r"""
-
+
@@ -429,16 +429,21 @@ APP_HTML = r"""
async function api(path, options = {}) {
const res = await fetch(path, { headers: { "Content-Type": "application/json" }, ...options });
- let data = null;
- try {
- data = await res.clone().json();
- } catch (_err) {
- data = { error: await res.text().catch(() => "") };
- }
+ const data = await readApiResponse(res);
if (!res.ok) throw new Error(data.error || "Request failed");
return data;
}
+ async function readApiResponse(res) {
+ try {
+ return await res.clone().json();
+ } catch (_err) {
+ const text = await res.text().catch(() => "");
+ const stripped = text.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
+ return { error: stripped || `${res.status} ${res.statusText || "Request failed"}` };
+ }
+ }
+
function renderStatus() {
const build = state.build || {};
const latest = build.current || (build.recent && build.recent[build.recent.length - 1]);
@@ -1015,21 +1020,97 @@ APP_HTML = r"""
pageLinkPicker.hidden = true;
}
+ const MAX_UPLOAD_BYTES = 750 * 1024;
+ const MAX_UPLOAD_DIMENSION = 1800;
+ const MIN_UPLOAD_DIMENSION = 900;
+
+ function extensionOf(filename) {
+ const match = /\.([a-z0-9]+)$/i.exec(filename || "");
+ return match ? match[1].toLowerCase() : "";
+ }
+
+ function canCompressImage(file) {
+ const ext = extensionOf(file.name);
+ const compressibleExt = ["jpg", "jpeg", "png", "webp", "heic", "heif"];
+ return (file.type.startsWith("image/") || compressibleExt.includes(ext)) && !["gif", "svg"].includes(ext);
+ }
+
+ function loadImage(file) {
+ return new Promise((resolve, reject) => {
+ const url = URL.createObjectURL(file);
+ const img = new Image();
+ img.onload = () => {
+ URL.revokeObjectURL(url);
+ resolve(img);
+ };
+ img.onerror = () => {
+ URL.revokeObjectURL(url);
+ reject(new Error("This browser could not read that image format."));
+ };
+ img.src = url;
+ });
+ }
+
+ function canvasToBlob(canvas, quality) {
+ return new Promise((resolve, reject) => {
+ canvas.toBlob((blob) => {
+ if (blob) resolve(blob);
+ else reject(new Error("Could not prepare image for upload."));
+ }, "image/jpeg", quality);
+ });
+ }
+
+ async function prepareImageUpload(file) {
+ const ext = extensionOf(file.name);
+ if (!canCompressImage(file) || (file.size <= MAX_UPLOAD_BYTES && !["heic", "heif"].includes(ext))) {
+ return file;
+ }
+
+ const img = await loadImage(file);
+ let maxDimension = MAX_UPLOAD_DIMENSION;
+ let quality = 0.82;
+ const stem = (file.name || "image").replace(/\.[^.]+$/, "") || "image";
+
+ for (let attempt = 0; attempt < 8; attempt += 1) {
+ const scale = Math.min(1, maxDimension / Math.max(img.naturalWidth || img.width, img.naturalHeight || img.height));
+ const width = Math.max(1, Math.round((img.naturalWidth || img.width) * scale));
+ const height = Math.max(1, Math.round((img.naturalHeight || img.height) * scale));
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext("2d");
+ ctx.fillStyle = "#fff";
+ ctx.fillRect(0, 0, width, height);
+ ctx.drawImage(img, 0, 0, width, height);
+
+ const blob = await canvasToBlob(canvas, quality);
+ if (blob.size <= MAX_UPLOAD_BYTES || maxDimension <= MIN_UPLOAD_DIMENSION) {
+ return new File([blob], `${stem}.jpg`, { type: "image/jpeg", lastModified: Date.now() });
+ }
+ maxDimension = Math.max(MIN_UPLOAD_DIMENSION, Math.round(maxDimension * 0.82));
+ quality = Math.max(0.62, quality - 0.05);
+ }
+
+ return file;
+ }
+
async function uploadAttachment() {
const file = attachInput.files[0];
if (!file) return;
- saveMessage.textContent = "Uploading image.";
- const body = new FormData();
- body.append("attachment", file);
- body.append("pagePath", currentEditorPath() || "index.org");
+ saveMessage.textContent = "Preparing image.";
try {
+ const uploadFile = await prepareImageUpload(file);
+ const body = new FormData();
+ body.append("attachment", uploadFile, uploadFile.name);
+ body.append("pagePath", currentEditorPath() || "index.org");
+ saveMessage.textContent = uploadFile.size < file.size ? "Uploading optimized image." : "Uploading image.";
const res = await fetch("/api/upload", { method: "POST", body });
- const data = await res.json();
+ const data = await readApiResponse(res);
if (!res.ok) throw new Error(data.error || "Upload failed");
replaceSelection(`\n${data.insertText || data.markdown}\n`);
saveMessage.textContent = "Image inserted.";
} catch (err) {
- saveMessage.textContent = err.message;
+ saveMessage.textContent = err.message || "Upload failed";
} finally {
attachInput.value = "";
}
@@ -3487,4 +3568,3 @@ HIDDEN_APP_HTML = r"""