mirror of
https://github.com/PR0M3TH3AN/unredact.git
synced 2026-10-27 08:40:29 +00:00
Add interactive font tuning UI with split-panel layout
Replace static overlay with client-side canvas rendering. Left panel
shows clickable OCR lines with font controls (dropdown + size slider).
Right panel renders green text overlay on the document via canvas.
Fonts loaded from backend TTF files using the FontFace API.
New endpoints: GET /api/fonts, GET /api/font/{id}
Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
1 parent
9b23ad827b
commit
b9df273521
5 files changed
+477
-48
No files matched your search
@@ -79,4 +79,51 @@ async def test_get_page_data(pdf_bytes: bytes):
|
||||
line = data["lines"][0]
|
||||
assert "font" in line
|
||||
assert line["font"]["name"]
|
||||
assert line["font"]["id"] # new: url-safe slug
|
||||
assert line["font"]["size"] > 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_fonts():
|
||||
"""GET /api/fonts should return list of candidate fonts."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get("/api/fonts")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "fonts" in data
|
||||
assert len(data["fonts"]) > 0
|
||||
font = data["fonts"][0]
|
||||
assert "name" in font
|
||||
assert "id" in font
|
||||
assert "available" in font
|
||||
# At least one font should be available on the system
|
||||
assert any(f["available"] for f in data["fonts"])
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_font_ttf():
|
||||
"""GET /api/font/{id} should serve a TTF file."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
# Get list first to find an available font
|
||||
resp = await client.get("/api/fonts")
|
||||
fonts = resp.json()["fonts"]
|
||||
available = [f for f in fonts if f["available"]]
|
||||
assert available, "No fonts available on system"
|
||||
|
||||
font_id = available[0]["id"]
|
||||
resp = await client.get(f"/api/font/{font_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "font/ttf"
|
||||
# TTF files start with specific bytes or have substantial size
|
||||
assert len(resp.content) > 1000
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_font_not_found():
|
||||
"""GET /api/font/{id} with bad id should 404."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get("/api/font/nonexistent-font")
|
||||
assert resp.status_code == 404
|
||||
+32
-1
@@ -1,4 +1,5 @@
|
||||
import io
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
@@ -10,7 +11,7 @@ from PIL import Image
|
||||
|
||||
from unredact.pipeline.rasterize import rasterize_pdf
|
||||
from unredact.pipeline.ocr import ocr_page
|
||||
from unredact.pipeline.font_detect import detect_fonts
|
||||
from unredact.pipeline.font_detect import detect_fonts, CANDIDATE_FONTS, _find_font_path
|
||||
from unredact.pipeline.overlay import render_overlay
|
||||
|
||||
app = FastAPI(title="Unredact")
|
||||
@@ -21,6 +22,22 @@ _docs: dict[str, dict] = {}
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
def _make_font_id(name: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||||
|
||||
|
||||
# Build font lookup at module level
|
||||
_font_id_to_path: dict[str, Path] = {}
|
||||
_font_list: list[dict] = []
|
||||
|
||||
for _name in CANDIDATE_FONTS:
|
||||
_fid = _make_font_id(_name)
|
||||
_path = _find_font_path(_name)
|
||||
if _path:
|
||||
_font_id_to_path[_fid] = _path
|
||||
_font_list.append({"name": _name, "id": _fid, "available": _path is not None})
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return RedirectResponse("/static/index.html")
|
||||
@@ -91,6 +108,19 @@ async def get_page_overlay(doc_id: str, page: int):
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
@app.get("/api/fonts")
|
||||
async def list_fonts():
|
||||
return {"fonts": _font_list}
|
||||
|
||||
|
||||
@app.get("/api/font/{font_id}")
|
||||
async def get_font(font_id: str):
|
||||
path = _font_id_to_path.get(font_id)
|
||||
if not path:
|
||||
return JSONResponse({"error": "font not found"}, status_code=404)
|
||||
return Response(content=path.read_bytes(), media_type="font/ttf")
|
||||
|
||||
|
||||
@app.get("/api/doc/{doc_id}/page/{page}/data")
|
||||
async def get_page_data(doc_id: str, page: int):
|
||||
doc = _docs.get(doc_id)
|
||||
@@ -111,6 +141,7 @@ async def get_page_data(doc_id: str, page: int):
|
||||
"chars": chars_json,
|
||||
"font": {
|
||||
"name": fm.font_name,
|
||||
"id": _make_font_id(fm.font_name),
|
||||
"size": fm.font_size,
|
||||
"score": fm.score,
|
||||
},
|
||||
|
||||
+235
-25
@@ -1,22 +1,69 @@
|
||||
// DOM elements
|
||||
const dropZone = document.getElementById("drop-zone");
|
||||
const fileInput = document.getElementById("file-input");
|
||||
const uploadSection = document.getElementById("upload-section");
|
||||
const viewerSection = document.getElementById("viewer-section");
|
||||
const docImage = document.getElementById("doc-image");
|
||||
const canvas = document.getElementById("overlay-canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const pageInfo = document.getElementById("page-info");
|
||||
const prevBtn = document.getElementById("prev-page");
|
||||
const nextBtn = document.getElementById("next-page");
|
||||
const overlayToggle = document.getElementById("show-overlay");
|
||||
const fontInfo = document.getElementById("font-info");
|
||||
const lineList = document.getElementById("line-list");
|
||||
const fontControls = document.getElementById("font-controls");
|
||||
const fontSelect = document.getElementById("font-select");
|
||||
const sizeSlider = document.getElementById("size-slider");
|
||||
const sizeValue = document.getElementById("size-value");
|
||||
const sizeDown = document.getElementById("size-down");
|
||||
const sizeUp = document.getElementById("size-up");
|
||||
|
||||
let state = {
|
||||
// State
|
||||
const state = {
|
||||
docId: null,
|
||||
pageCount: 0,
|
||||
currentPage: 1,
|
||||
pageData: {},
|
||||
pageData: {}, // page -> {lines: [...]}
|
||||
selectedLine: null, // index into current page's lines
|
||||
lineOverrides: {}, // "page-lineIdx" -> {fontId, fontSize}
|
||||
fonts: [], // [{name, id, available}]
|
||||
fontsReady: false,
|
||||
};
|
||||
|
||||
// Drag and drop
|
||||
// ── Font loading ──
|
||||
|
||||
async function loadFonts() {
|
||||
const resp = await fetch("/api/fonts");
|
||||
const data = await resp.json();
|
||||
state.fonts = data.fonts;
|
||||
|
||||
// Load available fonts via FontFace API
|
||||
const promises = state.fonts
|
||||
.filter((f) => f.available)
|
||||
.map(async (f) => {
|
||||
const face = new FontFace(f.name, `url(/api/font/${f.id})`);
|
||||
try {
|
||||
const loaded = await face.load();
|
||||
document.fonts.add(loaded);
|
||||
} catch (e) {
|
||||
console.warn(`Failed to load font ${f.name}:`, e);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
state.fontsReady = true;
|
||||
|
||||
// Populate font dropdown
|
||||
fontSelect.innerHTML = "";
|
||||
for (const f of state.fonts.filter((f) => f.available)) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = f.id;
|
||||
opt.textContent = f.name;
|
||||
fontSelect.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drag and drop ──
|
||||
|
||||
dropZone.addEventListener("click", () => fileInput.click());
|
||||
dropZone.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
@@ -32,12 +79,16 @@ fileInput.addEventListener("change", () => {
|
||||
if (fileInput.files.length) uploadFile(fileInput.files[0]);
|
||||
});
|
||||
|
||||
// ── Upload ──
|
||||
|
||||
async function uploadFile(file) {
|
||||
uploadSection.innerHTML = '<p class="loading">Analyzing document...</p>';
|
||||
|
||||
// Start font loading in parallel with upload
|
||||
const fontPromise = loadFonts();
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
|
||||
const resp = await fetch("/api/upload", { method: "POST", body: form });
|
||||
const data = await resp.json();
|
||||
|
||||
@@ -45,41 +96,36 @@ async function uploadFile(file) {
|
||||
state.pageCount = data.page_count;
|
||||
state.currentPage = 1;
|
||||
|
||||
await fontPromise; // ensure fonts are ready before rendering
|
||||
|
||||
uploadSection.hidden = true;
|
||||
viewerSection.hidden = false;
|
||||
|
||||
await loadPage(1);
|
||||
}
|
||||
|
||||
// ── Page loading ──
|
||||
|
||||
async function loadPage(page) {
|
||||
state.currentPage = page;
|
||||
updateControls();
|
||||
state.selectedLine = null;
|
||||
fontControls.hidden = true;
|
||||
updatePageControls();
|
||||
|
||||
const showOverlay = overlayToggle.checked;
|
||||
const endpoint = showOverlay ? "overlay" : "original";
|
||||
docImage.src = `/api/doc/${state.docId}/page/${page}/${endpoint}`;
|
||||
// Load the original page image
|
||||
docImage.src = `/api/doc/${state.docId}/page/${page}/original`;
|
||||
|
||||
// Load page data (font info, lines)
|
||||
// Load page data if not cached
|
||||
if (!state.pageData[page]) {
|
||||
const resp = await fetch(`/api/doc/${state.docId}/page/${page}/data`);
|
||||
state.pageData[page] = await resp.json();
|
||||
}
|
||||
|
||||
const pd = state.pageData[page];
|
||||
// Summarize per-line fonts: count unique font+size combos
|
||||
const fontCounts = {};
|
||||
for (const line of pd.lines) {
|
||||
const key = `${line.font.name} ${line.font.size}px`;
|
||||
fontCounts[key] = (fontCounts[key] || 0) + 1;
|
||||
}
|
||||
const summary = Object.entries(fontCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => `${k} (${v} lines)`)
|
||||
.join(", ");
|
||||
fontInfo.textContent = summary;
|
||||
renderLineList();
|
||||
clearCanvas();
|
||||
}
|
||||
|
||||
function updateControls() {
|
||||
function updatePageControls() {
|
||||
pageInfo.textContent = `Page ${state.currentPage} / ${state.pageCount}`;
|
||||
prevBtn.disabled = state.currentPage <= 1;
|
||||
nextBtn.disabled = state.currentPage >= state.pageCount;
|
||||
@@ -91,4 +137,168 @@ prevBtn.addEventListener("click", () => {
|
||||
nextBtn.addEventListener("click", () => {
|
||||
if (state.currentPage < state.pageCount) loadPage(state.currentPage + 1);
|
||||
});
|
||||
overlayToggle.addEventListener("change", () => loadPage(state.currentPage));
|
||||
|
||||
// ── Line list ──
|
||||
|
||||
function renderLineList() {
|
||||
const pd = state.pageData[state.currentPage];
|
||||
if (!pd) return;
|
||||
|
||||
lineList.innerHTML = "";
|
||||
pd.lines.forEach((line, idx) => {
|
||||
const div = document.createElement("div");
|
||||
div.className = "line-item";
|
||||
div.dataset.idx = idx;
|
||||
|
||||
const textEl = document.createElement("div");
|
||||
textEl.className = "line-text";
|
||||
textEl.textContent = line.text;
|
||||
|
||||
const overrideKey = `${state.currentPage}-${idx}`;
|
||||
const override = state.lineOverrides[overrideKey];
|
||||
const fontName = override
|
||||
? state.fonts.find((f) => f.id === override.fontId)?.name || line.font.name
|
||||
: line.font.name;
|
||||
const fontSize = override ? override.fontSize : line.font.size;
|
||||
|
||||
const metaEl = document.createElement("div");
|
||||
metaEl.className = "line-meta";
|
||||
metaEl.textContent = `${fontName} ${fontSize}px (score: ${line.font.score.toFixed(1)})`;
|
||||
|
||||
div.appendChild(textEl);
|
||||
div.appendChild(metaEl);
|
||||
|
||||
div.addEventListener("click", () => selectLine(idx));
|
||||
lineList.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Line selection ──
|
||||
|
||||
function selectLine(idx) {
|
||||
state.selectedLine = idx;
|
||||
|
||||
// Update selected class
|
||||
lineList.querySelectorAll(".line-item").forEach((el, i) => {
|
||||
el.classList.toggle("selected", i === idx);
|
||||
});
|
||||
|
||||
const pd = state.pageData[state.currentPage];
|
||||
const line = pd.lines[idx];
|
||||
const overrideKey = `${state.currentPage}-${idx}`;
|
||||
const override = state.lineOverrides[overrideKey];
|
||||
|
||||
// Populate font controls
|
||||
const fontId = override ? override.fontId : line.font.id;
|
||||
const fontSize = override ? override.fontSize : line.font.size;
|
||||
fontSelect.value = fontId;
|
||||
sizeSlider.value = fontSize;
|
||||
sizeValue.textContent = fontSize;
|
||||
fontControls.hidden = false;
|
||||
|
||||
renderOverlay();
|
||||
scrollToLine(line);
|
||||
}
|
||||
|
||||
function scrollToLine(line) {
|
||||
// Scroll the right panel so the selected line is visible
|
||||
const rightPanel = document.getElementById("right-panel");
|
||||
const imgRect = docImage.getBoundingClientRect();
|
||||
const scale = docImage.clientHeight / docImage.naturalHeight;
|
||||
const lineY = line.y * scale;
|
||||
const panelRect = rightPanel.getBoundingClientRect();
|
||||
const lineScreenY = imgRect.top - panelRect.top + lineY;
|
||||
// Center the line in the panel
|
||||
const target = lineScreenY - rightPanel.clientHeight / 3;
|
||||
rightPanel.scrollTop += target;
|
||||
}
|
||||
|
||||
// ── Font controls ──
|
||||
|
||||
fontSelect.addEventListener("change", () => saveOverrideAndRender());
|
||||
sizeSlider.addEventListener("input", () => {
|
||||
sizeValue.textContent = sizeSlider.value;
|
||||
saveOverrideAndRender();
|
||||
});
|
||||
sizeDown.addEventListener("click", () => {
|
||||
sizeSlider.value = Math.max(8, parseInt(sizeSlider.value) - 1);
|
||||
sizeValue.textContent = sizeSlider.value;
|
||||
saveOverrideAndRender();
|
||||
});
|
||||
sizeUp.addEventListener("click", () => {
|
||||
sizeSlider.value = Math.min(120, parseInt(sizeSlider.value) + 1);
|
||||
sizeValue.textContent = sizeSlider.value;
|
||||
saveOverrideAndRender();
|
||||
});
|
||||
|
||||
function saveOverrideAndRender() {
|
||||
if (state.selectedLine === null) return;
|
||||
const key = `${state.currentPage}-${state.selectedLine}`;
|
||||
state.lineOverrides[key] = {
|
||||
fontId: fontSelect.value,
|
||||
fontSize: parseInt(sizeSlider.value),
|
||||
};
|
||||
renderOverlay();
|
||||
// Update the meta text in the line list
|
||||
const items = lineList.querySelectorAll(".line-item");
|
||||
if (items[state.selectedLine]) {
|
||||
const meta = items[state.selectedLine].querySelector(".line-meta");
|
||||
const fontName = state.fonts.find((f) => f.id === fontSelect.value)?.name || "?";
|
||||
meta.textContent = `${fontName} ${sizeSlider.value}px (override)`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Canvas rendering ──
|
||||
|
||||
function clearCanvas() {
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
|
||||
function renderOverlay() {
|
||||
if (state.selectedLine === null || !state.fontsReady) return;
|
||||
if (!docImage.naturalWidth) return;
|
||||
|
||||
const pd = state.pageData[state.currentPage];
|
||||
const line = pd.lines[state.selectedLine];
|
||||
const overrideKey = `${state.currentPage}-${state.selectedLine}`;
|
||||
const override = state.lineOverrides[overrideKey];
|
||||
|
||||
const fontId = override ? override.fontId : line.font.id;
|
||||
const fontSize = override ? override.fontSize : line.font.size;
|
||||
const fontName = state.fonts.find((f) => f.id === fontId)?.name || line.font.name;
|
||||
|
||||
// Match canvas resolution to native image resolution
|
||||
canvas.width = docImage.naturalWidth;
|
||||
canvas.height = docImage.naturalHeight;
|
||||
// Match display size to image display size
|
||||
canvas.style.width = docImage.clientWidth + "px";
|
||||
canvas.style.height = docImage.clientHeight + "px";
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.font = `${fontSize}px "${fontName}"`;
|
||||
ctx.fillStyle = "rgba(0, 200, 0, 0.6)";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillText(line.text, line.x, line.y);
|
||||
|
||||
// Draw a subtle bounding box for reference
|
||||
ctx.strokeStyle = "rgba(0, 200, 0, 0.3)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(line.x, line.y, line.w, line.h);
|
||||
}
|
||||
|
||||
// ── Resize handling ──
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (state.selectedLine !== null) {
|
||||
renderOverlay();
|
||||
}
|
||||
});
|
||||
|
||||
// Start observing once image loads
|
||||
docImage.addEventListener("load", () => {
|
||||
resizeObserver.observe(docImage);
|
||||
if (state.selectedLine !== null) {
|
||||
renderOverlay();
|
||||
}
|
||||
});
|
||||
@@ -25,14 +25,32 @@
|
||||
<button id="prev-page" disabled>< Prev</button>
|
||||
<span id="page-info">Page 1 / 1</span>
|
||||
<button id="next-page" disabled>Next ></button>
|
||||
<label id="overlay-toggle">
|
||||
<input type="checkbox" id="show-overlay" checked>
|
||||
Show overlay
|
||||
</label>
|
||||
<span id="font-info"></span>
|
||||
</div>
|
||||
<div id="doc-container">
|
||||
<img id="doc-image" alt="Document page">
|
||||
|
||||
<div id="workspace">
|
||||
<div id="left-panel">
|
||||
<div id="line-list"></div>
|
||||
<div id="font-controls" hidden>
|
||||
<label>
|
||||
Font
|
||||
<select id="font-select"></select>
|
||||
</label>
|
||||
<div class="size-control">
|
||||
<span>Size</span>
|
||||
<button id="size-down" class="size-btn">-</button>
|
||||
<input type="range" id="size-slider" min="8" max="120" step="1">
|
||||
<button id="size-up" class="size-btn">+</button>
|
||||
<span id="size-value">32</span>px
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="right-panel">
|
||||
<div id="doc-container">
|
||||
<img id="doc-image" alt="Document page">
|
||||
<canvas id="overlay-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
+138
-15
@@ -5,10 +5,12 @@ body {
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 1.5rem 2rem;
|
||||
padding: 1rem 2rem;
|
||||
background: #16213e;
|
||||
border-bottom: 2px solid #0f3460;
|
||||
}
|
||||
@@ -26,7 +28,13 @@ header h1 {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
main { padding: 2rem; }
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#drop-zone {
|
||||
border: 2px dashed #0f3460;
|
||||
@@ -46,21 +54,23 @@ main { padding: 2rem; }
|
||||
|
||||
#drop-zone p { font-size: 1.1rem; color: #aaa; }
|
||||
|
||||
/* Controls bar */
|
||||
#controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#controls button {
|
||||
background: #0f3460;
|
||||
color: #e0e0e0;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -70,30 +80,143 @@ main { padding: 2rem; }
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#overlay-toggle {
|
||||
margin-left: auto;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
/* Workspace: split panel */
|
||||
#workspace {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#font-info {
|
||||
/* Left panel */
|
||||
#left-panel {
|
||||
width: 350px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#line-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.line-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.line-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.line-item.selected {
|
||||
border-left-color: #00d474;
|
||||
background: rgba(0, 212, 116, 0.1);
|
||||
}
|
||||
|
||||
.line-item .line-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.line-item .line-meta {
|
||||
font-size: 0.7rem;
|
||||
color: #888;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
/* Font controls */
|
||||
#font-controls {
|
||||
flex-shrink: 0;
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid #0f3460;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
#font-controls label,
|
||||
#font-controls .size-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
#font-select {
|
||||
flex: 1;
|
||||
background: #0f3460;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #1a3a6e;
|
||||
border-radius: 4px;
|
||||
padding: 0.3rem 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
#size-slider {
|
||||
flex: 1;
|
||||
accent-color: #00d474;
|
||||
}
|
||||
|
||||
.size-btn {
|
||||
background: #0f3460;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #1a3a6e;
|
||||
border-radius: 4px;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#size-value {
|
||||
color: #00d474;
|
||||
min-width: 2rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Right panel */
|
||||
#right-panel {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: #111;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
#doc-container {
|
||||
background: #111;
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
max-height: 80vh;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#doc-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#overlay-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: #00d474;
|
||||
text-align: center;
|
||||
|
||||
Reference in new issue
Block a user