Files
Umbraformat/umbraformat/static/js/main.js
2026-10-18 19:01:06 -07:00

402 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
let currentUploadId = null;
let analysisData = null;
const WORDS_PER_PAGE = {
"Caladea": { 10: 360, 11: 300, 12: 260 },
"Garamond": { 10: 340, 11: 285, 12: 245 },
"Baskerville": { 10: 350, 11: 295, 12: 255 },
"Palatino": { 10: 355, 11: 298, 12: 258 }
};
// UmbraFormat spec gutter table (spec section 2.1) — the server resolves
// this same table server-side; the UI only previews the expected value.
const MARGIN_TABLES = {
umbraformat: [
{ min: 0, max: 149, margin: 0.500 },
{ min: 150, max: 299, margin: 0.625 },
{ min: 300, max: Infinity, margin: 0.750 }
],
kdp: [
{ min: 0, max: 149, margin: 0.375 },
{ min: 150, max: 299, margin: 0.500 },
{ min: 300, max: 499, margin: 0.625 },
{ min: 500, max: 699, margin: 0.750 },
{ min: 700, max: Infinity, margin: 0.875 }
]
};
function getRecommendedMargin(pageCount, table) {
const rows = MARGIN_TABLES[table] || MARGIN_TABLES.umbraformat;
for (const row of rows) {
if (pageCount >= row.min && pageCount <= row.max) {
return row.margin;
}
}
return rows[rows.length - 1].margin;
}
function updateMarginRecommendation() {
const recDiv = document.getElementById('marginRecommendation');
const recSpan = document.getElementById('recommendedMargin');
if (window._pageCount) {
const rec = getRecommendedMargin(window._pageCount, 'umbraformat');
recSpan.textContent = `${rec} in for ~${window._pageCount} pages (resolved exactly from the rendered page count)`;
recDiv.classList.remove('d-none');
}
}
document.addEventListener("DOMContentLoaded", function () {
const dropZone = document.getElementById("dropZone");
const fileInput = document.getElementById("fileInput");
// Drag and drop
dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
dropZone.classList.add("drag-over");
});
dropZone.addEventListener("dragleave", () => {
dropZone.classList.remove("drag-over");
});
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
dropZone.classList.remove("drag-over");
if (e.dataTransfer.files.length > 0) {
handleFile(e.dataTransfer.files[0]);
}
});
// File input change
fileInput.addEventListener("change", () => {
if (fileInput.files.length > 0) {
handleFile(fileInput.files[0]);
}
});
// Format button
document.getElementById("formatBtn").addEventListener("click", doFormat);
});
function handleFile(file) {
if (!file.name.toLowerCase().endsWith(".docx")) {
showToast("Only .docx files are supported", "danger");
return;
}
if (file.size > 50 * 1024 * 1024) {
showToast("File exceeds 50 MB limit", "danger");
return;
}
showProgress(file.name, 0);
uploadFile(file);
}
function uploadFile(file) {
const formData = new FormData();
formData.append("file", file);
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
updateProgress(pct);
}
});
xhr.addEventListener("load", () => {
hideProgress();
if (xhr.status === 200) {
const data = JSON.parse(xhr.responseText);
if (data.error) {
showToast(data.error, "danger");
return;
}
currentUploadId = data.upload_id;
analysisData = data;
showResults(data);
} else {
let msg = "Upload failed";
try {
const err = JSON.parse(xhr.responseText);
msg = err.error || msg;
} catch (e) {}
showToast(msg, "danger");
}
});
xhr.addEventListener("error", () => {
hideProgress();
showToast("Network error during upload", "danger");
});
xhr.open("POST", "/api/analyze", true);
xhr.send(formData);
}
function showProgress(name, pct) {
document.getElementById("uploadFileName").textContent = name;
document.getElementById("uploadPercent").textContent = pct + "%";
document.getElementById("uploadProgressBar").style.width = pct + "%";
document.getElementById("uploadProgress").classList.remove("d-none");
}
function updateProgress(pct) {
document.getElementById("uploadPercent").textContent = pct + "%";
document.getElementById("uploadProgressBar").style.width = pct + "%";
}
function hideProgress() {
document.getElementById("uploadProgress").classList.add("d-none");
}
function showResults(data) {
document.getElementById("resultsArea").classList.remove("d-none");
document.getElementById("optionsArea").classList.remove("d-none");
document.getElementById("formatBtn").disabled = false;
analysisData = data;
updatePageEstimate();
renderAnalysis(data);
}
function updatePageEstimate() {
if (!analysisData) return;
// Body typography is fixed by the WA styles (Caladea 11pt, spec 2.2),
// so the estimate no longer depends on font selectors.
const trimSize = document.getElementById("trimSelect").value;
const wordCount = analysisData.chapter_word_count || 0;
const wpp = 300;
const trimFactor = trimSize === "6x9" ? 1.35 : 1.0;
const bodyPages = Math.ceil(wordCount / (wpp * trimFactor));
const totalPages = bodyPages + 10; // Add 10 for front/back matter
window._pageCount = Math.max(24, totalPages);
const pageInfo = document.getElementById("pageCountInfo");
pageInfo.innerHTML = `<i class="bi bi-file-text me-1"></i>${wordCount} words &middot; ~${totalPages} pages`;
updateMarginRecommendation();
}
function renderAnalysis(data) {
const panel = document.getElementById("analysisPanel");
const issueCount = (data.issues ? data.issues.length : 0) + (data.warnings ? data.warnings.length : 0);
document.getElementById("issueCount").textContent = issueCount + " items";
const s0 = data.sections && data.sections.length > 0 ? data.sections[0] : null;
let html = `<div class="mb-3 pb-2 border-bottom border-secondary">
<h6 class="text-info"><i class="bi bi-file-earmark-text me-2"></i>${escapeHtml(data.original_filename || "Untitled")}</h6>
</div>`;
// Page setup summary
html += `<div class="mb-3">
<h6 class="text-secondary mb-2"><i class="bi bi-layout-text-window-reverse"></i> Page Setup</h6>
<table class="table table-sm table-borderless text-secondary small mb-0">
<tr><td>Size</td><td class="text-end">${data.page_setup.width} x ${data.page_setup.height} in</td></tr>
<tr><td>Orientation</td><td class="text-end">${data.page_setup.orientation}</td></tr>`;
if (s0) {
html += `<tr><td>Margins</td><td class="text-end">T:${s0.top}" B:${s0.bottom}" I:${s0.left}" O:${s0.right}"</td></tr>`;
html += `<tr><td>Mirror margins</td><td class="text-end">${s0.mirror_margins ? "Yes" : '<span class="text-danger">No</span>'}</td></tr>`;
html += `<tr><td>Different first page</td><td class="text-end">${s0.different_first_page ? "Yes" : '<span class="text-danger">No</span>'}</td></tr>`;
}
html += `</table></div>`;
// Sections count
html += `<div class="mb-3">
<h6 class="text-secondary mb-2"><i class="bi bi-columns-gap"></i> Sections</h6>
<span class="text-secondary small">${data.sections ? data.sections.length : 0} total</span>
<span class="text-secondary small ms-3">Paragraphs: ${data.paragraph_count}</span>
</div>`;
// Styles
html += `<div class="mb-3">
<h6 class="text-secondary mb-2"><i class="bi bi-palette"></i> Styles</h6>`;
const styleNames = Object.keys(data.styles_found || {});
const missingStyles = data.styles_missing || [];
if (styleNames.length > 0) {
html += `<div class="small">`;
for (const [name, info] of Object.entries(data.styles_found)) {
const hasIssues = info.issues && info.issues.length > 0;
html += `<div class="style-check">
<i class="bi ${hasIssues ? 'bi-x-circle-fill text-danger' : 'bi-check-circle-fill'}"></i>
<span class="${hasIssues ? 'text-danger' : ''}">${name}</span>
<span class="text-secondary ms-auto">${info.count || 0} ¶</span>
</div>`;
}
html += `</div>`;
}
if (missingStyles.length > 0) {
html += `<div class="mt-2 small">`;
for (const name of missingStyles) {
html += `<div class="style-check">
<i class="bi bi-x-circle-fill text-danger"></i>
<span class="text-danger">${name}</span>
<span class="text-secondary ms-auto">MISSING</span>
</div>`;
}
html += `</div>`;
}
html += `</div>`;
// Issues and warnings
const allIssues = [
...(data.issues || []).map((i) => ({ text: i, type: "error" })),
...(data.warnings || []).map((w) => ({ text: w, type: "warning" })),
];
if (allIssues.length > 0) {
html += `<div>
<h6 class="text-secondary mb-2"><i class="bi bi-exclamation-triangle"></i> Issues</h6>`;
for (const issue of allIssues) {
const icon = issue.type === "error" ? "bi-x-circle" : "bi-exclamation-circle";
html += `<div class="issue-item ${issue.type} small">
<i class="bi ${icon} me-1"></i> ${escapeHtml(issue.text)}
</div>`;
}
html += `</div>`;
}
panel.innerHTML = html;
}
function doFormat() {
if (!currentUploadId) return;
document.getElementById("formatBtn").disabled = true;
showLoading("Formatting document...");
const trimSize = document.getElementById("trimSelect").value;
const bleed = document.getElementById("bleedCheck").checked ? 0.125 : 0;
const author = document.getElementById("authorInput").value.trim();
const imprint = document.getElementById("imprintInput").value.trim();
const padMultipleOf4 = document.getElementById("padMultipleCheck").checked;
const stripGraphics = document.getElementById("stripGraphicsCheck").checked;
const stripProblematicGraphics = document.getElementById("stripProblematicCheck").checked;
const language = document.getElementById("languageSelect").value;
fetch("/api/format", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
upload_id: currentUploadId,
trimSize: trimSize,
bleed: bleed,
author: author,
imprint: imprint,
padMultipleOf4: padMultipleOf4,
stripGraphics: stripGraphics,
stripProblematicGraphics: stripProblematicGraphics,
language: language
}),
})
.then((r) => r.json())
.then((data) => {
hideLoading();
document.getElementById("formatBtn").disabled = false;
if (data.error) {
showToast(data.error, "danger");
return;
}
showFormatted(data);
})
.catch((err) => {
hideLoading();
document.getElementById("formatBtn").disabled = false;
showToast("Formatting failed: " + err.message, "danger");
});
}
function showFormatted(data) {
const panel = document.getElementById("formatPanel");
const dlDiv = document.getElementById("downloadButtons");
const trimSize = document.getElementById("trimSelect").value;
const bleed = document.getElementById("bleedCheck").checked ? "0.125 in" : "none";
const trimLabel = trimSize === "6x9" ? "6 × 9 in" : "5.5 × 8.5 in";
// Enable downloads
dlDiv.classList.remove("d-none");
document.getElementById("downloadDocx").href = data.download_docx;
document.getElementById("downloadPdf").href = data.download_pdf;
panel.innerHTML = `
<div class="text-center mb-3">
<i class="bi bi-check-circle-fill text-success" style="font-size: 2.5rem;"></i>
<h5 class="mt-2 text-success">Formatting Complete</h5>
</div>
<div class="format-summary">
<h6 class="mb-3"><i class="bi bi-list-check me-2"></i>What was applied</h6>
<ul class="small text-secondary mb-0">
<li>Trim size: ${trimLabel}, portrait (bleed: ${bleed})</li>
<li>Mirrored margins: Inside computed from final page count / Outside 0.375 in</li>
<li>Top/Bottom margins: 0.40 in / 0.40 in</li>
<li>Body: WA styles (Caladea 11pt, justified, exactly 15pt line)</li>
<li>Chapter titles: Caladea 18pt</li>
<li>Different first page enabled on all sections</li>
<li>Major sections (TOC, chapters, back matter) start on a right-hand (recto) page</li>
<li>Page numbers: centered in the footer (running heads carry titles only)</li>
<li>Language: ${document.getElementById("languageSelect").value}</li>
<li>Front matter: lowercase Roman numerals</li>
<li>Chapter 1: restarts at page 1 (Arabic)</li>
<li>Styles applied: WA Chapter, WA Section, WA NChp Pgr, WA Nrml Pgr, WA Thkng, WA TOC</li>
<li>Section break markers standardized to <<<>>></li>
</ul>
</div>
<div class="text-center mt-3">
<p class="small text-secondary">Download the formatted file using the buttons above.</p>
</div>
`;
}
function showLoading(text) {
document.getElementById("loadingText").textContent = text || "Processing...";
document.getElementById("loadingOverlay").classList.remove("d-none");
}
function hideLoading() {
document.getElementById("loadingOverlay").classList.add("d-none");
}
function showToast(msg, type) {
const container = document.getElementById("toastContainer") || (() => {
const c = document.createElement("div");
c.id = "toastContainer";
c.style.cssText = "position:fixed;top:20px;right:20px;z-index:99999";
document.body.appendChild(c);
return c;
})();
const colors = { danger: "bg-danger", warning: "bg-warning text-dark", success: "bg-success", info: "bg-info" };
const cls = colors[type] || "bg-secondary";
const el = document.createElement("div");
el.className = `toast align-items-center text-white border-0 ${cls} show`;
el.role = "alert";
el.innerHTML = `<div class="d-flex">
<div class="toast-body">${escapeHtml(msg)}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>`;
container.appendChild(el);
setTimeout(() => { el.remove(); }, 5000);
}
function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}