- 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).
409 lines
15 KiB
JavaScript
409 lines
15 KiB
JavaScript
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 · ~${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;
|
||
}
|