Files
Umbraformat/umbraformat/static/js/main.js
bm 166a4849da spec section 2.2 WA style family: authoritative styles + dynamic gutter
- spec: new section 2.2 style table (WA Chapter/Section/NChp Pgr/Nrml Pgr/
  Thkng + TOC mapping), margins 0.40/0.40/0.375 + 0.25 header-footer
  distance, dynamic gutter resolution procedure, header Caladea 10pt caps,
  footer 11pt, justified body without auto-hyphenation, WA Section reserved
  for scene breaks.
- formatter: WA_STYLES rebuilt to the spec values (next-style chain, outline
  level on WA Chapter, automatic color, widow control); content assigned by
  style reference with direct paragraph/run formatting removed; scene breaks
  use WA Section; TOC entries use WA TOC; auto-hyphenation removed; new page
  setup constants; header ALL CAPS 10pt / footer 11pt.
- format_document: inside/gutter margin now computed from the final page
  count (build -> render -> recompute fixed point, cap 3, settle on larger
  gutter on oscillation); inner_margin input ignored (UI selector removed).
- tests: 68 pass, incl. style-family value tests and mocked gutter-loop tests.
2026-09-24 07:53:19 -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.

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 marginTable = document.getElementById('marginTableSelect');
if (window._pageCount) {
const rec = getRecommendedMargin(window._pageCount, marginTable.value);
recSpan.textContent = `${rec} in for ${window._pageCount} pages (applied automatically)`;
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;
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 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,
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;
}