Skip to content

Commit 96426de

Browse files
Timna BrownTimna Brown
authored andcommitted
Add live preset comparison, thermal fade, and preset recommendations
1 parent 9642e17 commit 96426de

3 files changed

Lines changed: 255 additions & 8 deletions

File tree

app.js

Lines changed: 154 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,11 @@ const minRemainingCropPercent = 10;
2626
const autoDetectCropPadding = 0.012;
2727

2828
const presetSettings = {
29-
clean: { brightness: 16, contrast: 114, grain: 1, vignette: 2, threshold: 188, shadowBoost: 0.14, binaryMix: 0.58, lineNoise: 0.014, tonerNoise: 0.006, scannerAge: 8 },
30-
classic: { brightness: 8, contrast: 132, grain: 6, vignette: 5, threshold: 166, shadowBoost: 0.28, binaryMix: 0.74, lineNoise: 0.028, tonerNoise: 0.012, scannerAge: 34 },
31-
"high-contrast": { brightness: 3, contrast: 152, grain: 3, vignette: 1, threshold: 150, shadowBoost: 0.4, binaryMix: 0.88, lineNoise: 0.018, tonerNoise: 0.009, scannerAge: 18 },
32-
thermal: { brightness: 6, contrast: 162, grain: 4, vignette: 0, threshold: 154, shadowBoost: 0.36, binaryMix: 0.92, lineNoise: 0.02, tonerNoise: 0.014, processingScale: 0.76, pureMono: true, streakStrength: 0.02, dropoutChance: 0.0011, scannerAge: 42, dotMatrixColumns: true },
33-
fax: { brightness: 0, contrast: 172, grain: 7, vignette: 1, threshold: 142, shadowBoost: 0.46, binaryMix: 1, lineNoise: 0.045, tonerNoise: 0.022, processingScale: 0.42, pureMono: true, streakStrength: 0.055, dropoutChance: 0.0024, scannerAge: 72 },
29+
clean: { brightness: 16, contrast: 114, grain: 1, vignette: 2, threshold: 188, shadowBoost: 0.14, binaryMix: 0.58, lineNoise: 0.014, tonerNoise: 0.006, scannerAge: 8, thermalFade: 0, ocrFirstRecommended: false },
30+
classic: { brightness: 8, contrast: 132, grain: 6, vignette: 5, threshold: 166, shadowBoost: 0.28, binaryMix: 0.74, lineNoise: 0.028, tonerNoise: 0.012, scannerAge: 34, thermalFade: 0, ocrFirstRecommended: false },
31+
"high-contrast": { brightness: 3, contrast: 152, grain: 3, vignette: 1, threshold: 150, shadowBoost: 0.4, binaryMix: 0.88, lineNoise: 0.018, tonerNoise: 0.009, scannerAge: 18, thermalFade: 0, ocrFirstRecommended: true },
32+
thermal: { brightness: 6, contrast: 162, grain: 4, vignette: 0, threshold: 154, shadowBoost: 0.36, binaryMix: 0.92, lineNoise: 0.02, tonerNoise: 0.014, processingScale: 0.76, pureMono: true, streakStrength: 0.02, dropoutChance: 0.0011, scannerAge: 42, thermalFade: 28, dotMatrixColumns: true, ocrFirstRecommended: false },
33+
fax: { brightness: 0, contrast: 172, grain: 7, vignette: 1, threshold: 142, shadowBoost: 0.46, binaryMix: 1, lineNoise: 0.045, tonerNoise: 0.022, processingScale: 0.42, pureMono: true, streakStrength: 0.055, dropoutChance: 0.0024, scannerAge: 72, thermalFade: 0, ocrFirstRecommended: false },
3434
};
3535

3636
const presetDescriptions = {
@@ -130,11 +130,15 @@ const elements = {
130130
scanLookToggle: document.querySelector("#scan-look-toggle"),
131131
preset: document.querySelector("#scan-preset"),
132132
presetDescription: document.querySelector("#preset-description"),
133+
presetRecommendation: document.querySelector("#preset-recommendation"),
134+
presetComparisonNote: document.querySelector("#preset-comparison-note"),
135+
presetComparisonGrid: document.querySelector("#preset-comparison-grid"),
133136
brightness: document.querySelector("#brightness"),
134137
contrast: document.querySelector("#contrast"),
135138
grain: document.querySelector("#grain"),
136139
vignette: document.querySelector("#vignette"),
137140
scannerAge: document.querySelector("#scanner-age"),
141+
thermalFade: document.querySelector("#thermal-fade"),
138142
ocrFirstMode: document.querySelector("#ocr-first-mode"),
139143
controlsGrid: document.querySelector(".controls-grid"),
140144
};
@@ -170,6 +174,7 @@ function getControls() {
170174
grain: Number(elements.grain.value),
171175
vignette: Number(elements.vignette.value),
172176
scannerAge: Number(elements.scannerAge.value),
177+
thermalFade: Number(elements.thermalFade.value),
173178
ocrFirstMode: elements.ocrFirstMode.checked,
174179
pageMargin: Number(elements.pageMargin.value),
175180
imageFit: elements.imageFit.value,
@@ -199,11 +204,44 @@ function getScanControls(controls = getControls()) {
199204
grain: controls.grain,
200205
vignette: controls.vignette,
201206
scannerAge: controls.scannerAge,
207+
thermalFade: controls.thermalFade,
202208
ocrFirstMode: controls.ocrFirstMode,
203209
autoCleanup: controls.autoCleanup,
204210
};
205211
}
206212

213+
function getPresetLabel(presetKey) {
214+
const option = elements.preset.querySelector(`[value="${presetKey}"]`);
215+
return option ? option.textContent : presetKey;
216+
}
217+
218+
function getPresetRecommendationText(presetKey) {
219+
const preset = presetSettings[presetKey];
220+
if (!preset) {
221+
return "";
222+
}
223+
224+
const parts = [
225+
`Brightness ${preset.brightness >= 0 ? "+" : ""}${preset.brightness}`,
226+
`Contrast ${preset.contrast}`,
227+
`Grain ${preset.grain}`,
228+
`Vignette ${preset.vignette}`,
229+
`Scanner age ${preset.scannerAge ?? 0}`,
230+
];
231+
232+
if ((preset.thermalFade ?? 0) > 0) {
233+
parts.push(`Thermal fade ${preset.thermalFade}`);
234+
}
235+
236+
parts.push(`OCR-first ${preset.ocrFirstRecommended ? "on" : "off"}`);
237+
return `Recommended defaults: ${parts.join(", ")}.`;
238+
}
239+
240+
function updatePresetSpecificControlState() {
241+
const thermalEnabled = elements.scanLookToggle.checked && elements.preset.value === "thermal";
242+
elements.thermalFade.disabled = !thermalEnabled;
243+
}
244+
207245
function getEffectiveControls(entry, controls = getControls()) {
208246
if (!entry?.scanSettings) {
209247
return controls;
@@ -1141,9 +1179,10 @@ function buildRenderCanvas(sourceImage, controls, options = {}, adjustments = cr
11411179
function updateScanControlState() {
11421180
const useScanLook = elements.scanLookToggle.checked;
11431181
elements.controlsGrid.classList.toggle("disabled", !useScanLook);
1144-
[elements.preset, elements.brightness, elements.contrast, elements.grain, elements.vignette, elements.scannerAge, elements.ocrFirstMode].forEach((input) => {
1182+
[elements.preset, elements.brightness, elements.contrast, elements.grain, elements.vignette, elements.scannerAge, elements.thermalFade, elements.ocrFirstMode].forEach((input) => {
11451183
input.disabled = !useScanLook;
11461184
});
1185+
updatePresetSpecificControlState();
11471186
}
11481187

11491188
function syncPresetToControls() {
@@ -1153,11 +1192,15 @@ function syncPresetToControls() {
11531192
elements.grain.value = preset.grain;
11541193
elements.vignette.value = preset.vignette;
11551194
elements.scannerAge.value = preset.scannerAge ?? 20;
1195+
elements.thermalFade.value = preset.thermalFade ?? 0;
11561196
updatePresetDescription();
11571197
}
11581198

11591199
function updatePresetDescription() {
11601200
elements.presetDescription.textContent = presetDescriptions[elements.preset.value];
1201+
elements.presetRecommendation.textContent = getPresetRecommendationText(elements.preset.value);
1202+
updatePresetSpecificControlState();
1203+
renderPresetComparison();
11611204
}
11621205

11631206
function canvasToBlob(canvas, mimeType, quality) {
@@ -1314,9 +1357,99 @@ function applyDotMatrixColumns(data, width, height, ageFactor) {
13141357
}
13151358
}
13161359

1360+
function getPresetComparisonEntry() {
1361+
if (state.activeAdjustId) {
1362+
return state.files.find((file) => file.id === state.activeAdjustId) || null;
1363+
}
1364+
1365+
return getVisibleEntries()[0] || state.files[0] || null;
1366+
}
1367+
1368+
function getPresetControlsForComparison(baseControls, presetKey) {
1369+
const preset = presetSettings[presetKey];
1370+
return {
1371+
...baseControls,
1372+
preset: presetKey,
1373+
brightness: preset.brightness,
1374+
contrast: preset.contrast,
1375+
grain: preset.grain,
1376+
vignette: preset.vignette,
1377+
scannerAge: preset.scannerAge ?? 0,
1378+
thermalFade: preset.thermalFade ?? 0,
1379+
};
1380+
}
1381+
1382+
function renderPresetComparison() {
1383+
if (!elements.presetComparisonGrid || !elements.presetComparisonNote) {
1384+
return;
1385+
}
1386+
1387+
elements.presetComparisonGrid.innerHTML = "";
1388+
1389+
if (!elements.scanLookToggle.checked) {
1390+
elements.presetComparisonNote.textContent = "Enable black-and-white scan to compare presets live.";
1391+
return;
1392+
}
1393+
1394+
const sourceEntry = getPresetComparisonEntry();
1395+
if (!sourceEntry) {
1396+
elements.presetComparisonNote.textContent = "Upload a page to compare presets side by side.";
1397+
return;
1398+
}
1399+
1400+
elements.presetComparisonNote.textContent = `Comparing presets on ${sourceEntry.name}.`;
1401+
const baseControls = getControls();
1402+
1403+
Object.keys(presetSettings).forEach((presetKey) => {
1404+
const card = document.createElement("article");
1405+
card.className = "preset-comparison-card";
1406+
if (presetKey === baseControls.preset) {
1407+
card.classList.add("is-active");
1408+
}
1409+
1410+
const title = document.createElement("h3");
1411+
title.className = "preset-comparison-title";
1412+
title.textContent = getPresetLabel(presetKey);
1413+
1414+
const frame = document.createElement("div");
1415+
frame.className = "preset-comparison-frame";
1416+
const canvas = document.createElement("canvas");
1417+
1418+
try {
1419+
const rendered = buildRenderCanvas(
1420+
sourceEntry.sourceImage,
1421+
getPresetControlsForComparison(baseControls, presetKey),
1422+
{ maxDimension: 240 },
1423+
getEntryAdjustments(sourceEntry)
1424+
);
1425+
canvas.width = rendered.width;
1426+
canvas.height = rendered.height;
1427+
const context = canvas.getContext("2d");
1428+
context.imageSmoothingEnabled = true;
1429+
context.imageSmoothingQuality = "medium";
1430+
context.drawImage(rendered, 0, 0);
1431+
} catch {
1432+
canvas.width = 1;
1433+
canvas.height = 1;
1434+
}
1435+
1436+
frame.appendChild(canvas);
1437+
1438+
const description = document.createElement("p");
1439+
description.className = "preset-comparison-description";
1440+
description.textContent = presetDescriptions[presetKey];
1441+
1442+
card.appendChild(title);
1443+
card.appendChild(frame);
1444+
card.appendChild(description);
1445+
elements.presetComparisonGrid.appendChild(card);
1446+
});
1447+
}
1448+
13171449
function applyScanEffect(sourceImage, controls, dimensions = { width: sourceImage.width, height: sourceImage.height }) {
13181450
const preset = presetSettings[controls.preset];
13191451
const ageFactor = Math.max(0, Math.min(1, (controls.scannerAge ?? preset.scannerAge ?? 0) / 100));
1452+
const thermalFade = preset.dotMatrixColumns ? Math.max(0, Math.min(1, (controls.thermalFade ?? preset.thermalFade ?? 0) / 100)) : 0;
13201453
const ocrFirstMode = Boolean(controls.ocrFirstMode);
13211454
const processingScale = Math.max(0.35, (preset.processingScale ?? 1) - ((preset.pureMono ? 0.12 : 0.04) * ageFactor));
13221455
const processingWidth = Math.max(1, Math.round(dimensions.width * processingScale));
@@ -1382,6 +1515,15 @@ function applyScanEffect(sourceImage, controls, dimensions = { width: sourceImag
13821515
grayscaleValue = grayscaleValue < rowThreshold ? 0 : 255;
13831516
}
13841517

1518+
if (thermalFade > 0 && grayscaleValue < 255) {
1519+
const fadePattern = ((Math.sin((x * 0.18) + (y * 0.05)) + Math.cos((y * 0.11) - (x * 0.03))) * 0.25) + 0.5 + (Math.random() * 0.12);
1520+
const fadeLift = thermalFade * (24 + (fadePattern * 112));
1521+
grayscaleValue = clamp(grayscaleValue + fadeLift);
1522+
if (grayscaleValue < 128 && fadePattern > (0.74 - (thermalFade * 0.18))) {
1523+
grayscaleValue = 255;
1524+
}
1525+
}
1526+
13851527
if (ocrFirstMode) {
13861528
if (grayscaleValue < 150) {
13871529
grayscaleValue = Math.max(0, grayscaleValue * 0.42);
@@ -1620,6 +1762,7 @@ function renderPreviews() {
16201762
elements.activeFiltersSummary.textContent = getActiveFilterSummary();
16211763
updateDownloadButtonLabel();
16221764
updateVisibleActionLabels();
1765+
renderPresetComparison();
16231766
if (hiddenByFilter) {
16241767
setStatus(
16251768
`${state.files.length} page${state.files.length > 1 ? "s" : ""} loaded, but the current page filter is hiding all of them. Switch the Pages filter to Show all pages to restore the preview.${elements.exportVisibleOnly.checked ? " Visible-only export is also hiding downloads." : ""}`
@@ -2713,6 +2856,11 @@ elements.selectDetectedBlanks.addEventListener("change", updateBlankActionLabel)
27132856
});
27142857
});
27152858

2859+
elements.thermalFade.addEventListener("input", () => {
2860+
scheduleRenderPreviews();
2861+
renderAdjustPreview();
2862+
});
2863+
27162864
elements.ocrFirstMode.addEventListener("change", () => {
27172865
scheduleRenderPreviews();
27182866
renderAdjustPreview();

index.html

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Manrope:wght@400;500;600;700;800&display=swap"
1515
rel="stylesheet"
1616
/>
17-
<link rel="stylesheet" href="./styles.css?v=20260326-3" />
17+
<link rel="stylesheet" href="./styles.css?v=20260326-4" />
1818
</head>
1919
<body>
2020
<div class="page-shell">
@@ -130,8 +130,20 @@ <h1>Turn photos into clean PDF documents right in your browser.</h1>
130130
<p id="preset-description" class="control-hint" aria-live="polite">
131131
Office scanner keeps pages light and clean with the least texture.
132132
</p>
133+
<p id="preset-recommendation" class="control-hint preset-recommendation" aria-live="polite"></p>
133134
</div>
134135

136+
<section class="preset-comparison-panel" aria-labelledby="preset-comparison-title">
137+
<div class="preset-comparison-header">
138+
<div>
139+
<p class="card-label">Live Compare</p>
140+
<h3 id="preset-comparison-title">Preset comparison</h3>
141+
</div>
142+
<p id="preset-comparison-note" class="section-note">Upload a page to compare presets side by side.</p>
143+
</div>
144+
<div id="preset-comparison-grid" class="preset-comparison-grid"></div>
145+
</section>
146+
135147
<details class="control-disclosure" open>
136148
<summary>Scan tuning</summary>
137149
<div class="controls-grid">
@@ -160,6 +172,11 @@ <h1>Turn photos into clean PDF documents right in your browser.</h1>
160172
<input id="scanner-age" type="range" min="0" max="100" value="8" />
161173
</label>
162174

175+
<label>
176+
<span>Thermal fade</span>
177+
<input id="thermal-fade" type="range" min="0" max="100" value="0" />
178+
</label>
179+
163180
<label class="checkbox-control">
164181
<span>OCR-first cleanup</span>
165182
<input id="ocr-first-mode" type="checkbox" />
@@ -440,6 +457,6 @@ <h2 id="adjust-title">Straighten and crop</h2>
440457
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>
441458
<script src="https://cdn.jsdelivr.net/npm/utif@3.1.0/UTIF.min.js"></script>
442459
<script async src="https://docs.opencv.org/4.x/opencv.js"></script>
443-
<script src="./app.js?v=20260326-3" defer></script>
460+
<script src="./app.js?v=20260326-4" defer></script>
444461
</body>
445462
</html>

styles.css

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,84 @@ h1 {
386386
line-height: 1.5;
387387
}
388388

389+
.preset-recommendation {
390+
margin-top: -10px;
391+
padding: 12px 14px;
392+
border-radius: 14px;
393+
border: 1px solid rgba(74, 58, 39, 0.08);
394+
background: rgba(255, 255, 255, 0.68);
395+
}
396+
397+
.preset-comparison-panel {
398+
margin-top: 18px;
399+
padding: 18px;
400+
border-radius: 18px;
401+
border: 1px solid rgba(74, 58, 39, 0.1);
402+
background: rgba(255, 255, 255, 0.56);
403+
}
404+
405+
.preset-comparison-header {
406+
display: flex;
407+
justify-content: space-between;
408+
gap: 16px;
409+
align-items: start;
410+
}
411+
412+
.preset-comparison-header h3 {
413+
margin: 2px 0 0;
414+
font-size: 1rem;
415+
}
416+
417+
.preset-comparison-grid {
418+
display: grid;
419+
grid-template-columns: repeat(auto-fit, minmax(128px, 1fr));
420+
gap: 12px;
421+
margin-top: 16px;
422+
}
423+
424+
.preset-comparison-card {
425+
display: grid;
426+
gap: 10px;
427+
padding: 12px;
428+
border-radius: 16px;
429+
background: rgba(255, 255, 255, 0.74);
430+
border: 1px solid rgba(74, 58, 39, 0.1);
431+
}
432+
433+
.preset-comparison-card.is-active {
434+
border-color: rgba(163, 92, 45, 0.42);
435+
box-shadow: 0 0 0 2px rgba(163, 92, 45, 0.08);
436+
}
437+
438+
.preset-comparison-title {
439+
margin: 0;
440+
font-size: 0.92rem;
441+
font-weight: 800;
442+
}
443+
444+
.preset-comparison-frame {
445+
border-radius: 12px;
446+
overflow: hidden;
447+
background: #fff;
448+
border: 1px solid rgba(74, 58, 39, 0.08);
449+
aspect-ratio: 4 / 5;
450+
display: grid;
451+
place-items: center;
452+
}
453+
454+
.preset-comparison-frame canvas {
455+
width: 100%;
456+
height: 100%;
457+
object-fit: contain;
458+
}
459+
460+
.preset-comparison-description {
461+
margin: 0;
462+
color: var(--muted);
463+
font-size: 0.82rem;
464+
line-height: 1.45;
465+
}
466+
389467
select,
390468
input[type="text"],
391469
input[type="range"] {
@@ -970,6 +1048,10 @@ button:disabled {
9701048
flex-direction: column;
9711049
}
9721050

1051+
.preset-comparison-header {
1052+
flex-direction: column;
1053+
}
1054+
9731055
.adjust-layout {
9741056
grid-template-columns: 1fr;
9751057
}

0 commit comments

Comments
 (0)