Files
Umbraformat/umbraformat/static/js/main.js
bm 0c32168231 Apply updated spec compliance and establish git/docker baseline
- Spec §4.1: running headers carry titles only (recto: chapter/section
  title; verso: author name + book title); page-number fields removed
  from headers.
- Spec §4.2: page numbers centered in the footer on both verso and recto
  pages; front-matter display pages (copyright, dedication, epigraph,
  TOC) show their Roman numeral on the opening page too.
- Spec §2.2: lowercase Roman numbering visible from the copyright page
  through the front matter; Chapter 1 restarts at Arabic 1.
- Chapter/back-matter sections keep running matter on body pages; only
  the first page of each chapter is suppressed (spec §4.3).
- Drop titlePg on single-page front-matter display sections so footer
  page numbers render in LibreOffice and Word alike.
- Preserve <<<>>> scene-break cues (centered, 12pt before/after) and
  first-word-only capitalization of chapter openers.
- UI: remove the obsolete "Page Numbers" selector; footer_mode is
  accepted-but-ignored for backward compatibility.
- Tests updated to the new spec behavior (51 pass).
2026-09-22 19:06:03 -07:00

409 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.

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 }
};
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');
const marginSelect = document.getElementById('innerMarginSelect');
const marginTable = document.getElementById('marginTableSelect');
if (window._pageCount) {
const rec = getRecommendedMargin(window._pageCount, marginTable.value);
recSpan.textContent = `${rec} in for ${window._pageCount} pages`;
recDiv.classList.remove('d-none');
marginSelect.value = rec;
}
}
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;
const font = document.getElementById("fontSelect").value;
const fontSize = parseInt(document.getElementById("fontSizeSelect").value);
const trimSize = document.getElementById("trimSelect").value;
const wordCount = analysisData.chapter_word_count || 0;
const wpp = (WORDS_PER_PAGE[font] && WORDS_PER_PAGE[font][fontSize]) || 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 font = document.getElementById("fontSelect").value;
const fontSize = document.getElementById("fontSizeSelect").value;
const innerMargin = document.getElementById("innerMarginSelect").value;
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,
font: font,
fontSize: fontSize,
innerMargin: innerMargin,
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 font = document.getElementById("fontSelect").value;
const fontSize = document.getElementById("fontSizeSelect").value;
const innerMargin = document.getElementById("innerMarginSelect").value;
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 ${innerMargin} in / Outside 0.50 in</li>
<li>Top/Bottom margins: 0.75 in / 0.75 in</li>
<li>Font: ${font} ${fontSize}pt (body paragraphs)</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 Nrml Pgr, WA NChp Pgr</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;
}