better pdf ux
This commit is contained in:
@@ -145,11 +145,12 @@ def torrent_folder_priority(torrent_hash: str):
|
|||||||
return ok(result), status
|
return ok(result), status
|
||||||
|
|
||||||
|
|
||||||
def _attachment_headers(download_name: str, content_type: str = "application/octet-stream") -> dict:
|
def _attachment_headers(download_name: str, content_type: str = "application/octet-stream", disposition: str = "attachment") -> dict:
|
||||||
safe = Path(download_name or "download.bin").name or "download.bin"
|
safe = Path(download_name or "download.bin").name or "download.bin"
|
||||||
|
safe_disposition = "inline" if disposition == "inline" else "attachment"
|
||||||
return {
|
return {
|
||||||
"Content-Type": content_type,
|
"Content-Type": content_type,
|
||||||
"Content-Disposition": f"attachment; filename*=UTF-8''{quote(safe)}",
|
"Content-Disposition": f"{safe_disposition}; filename*=UTF-8''{quote(safe)}",
|
||||||
"X-Content-Type-Options": "nosniff",
|
"X-Content-Type-Options": "nosniff",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +207,10 @@ def torrent_file_download(torrent_hash: str, file_index: int):
|
|||||||
try:
|
try:
|
||||||
item = rtorrent.torrent_download_file_info(profile, torrent_hash, file_index)
|
item = rtorrent.torrent_download_file_info(profile, torrent_hash, file_index)
|
||||||
size = int(item.get("size") or 0)
|
size = int(item.get("size") or 0)
|
||||||
headers = _attachment_headers(item.get("download_name") or "file.bin")
|
download_name = item.get("download_name") or "file.bin"
|
||||||
|
inline_pdf = str(request.args.get("disposition") or "").lower() == "inline" and Path(download_name).suffix.lower() == ".pdf"
|
||||||
|
# Note: Inline mode is limited to PDFs so the existing download behavior remains unchanged for every other file type.
|
||||||
|
headers = _attachment_headers(download_name, "application/pdf" if inline_pdf else "application/octet-stream", "inline" if inline_pdf else "attachment")
|
||||||
if size > 0:
|
if size > 0:
|
||||||
headers["Content-Length"] = str(size)
|
headers["Content-Length"] = str(size)
|
||||||
def generate():
|
def generate():
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ def _image_preview_supported(path: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _pdf_preview_supported(path: str) -> bool:
|
def _pdf_preview_supported(path: str) -> bool:
|
||||||
# Note: PDF previews use pypdf for bounded text extraction and do not require system tools such as poppler.
|
# Note: PDF previews are rendered inline by the browser so image-heavy books keep their page layout.
|
||||||
return _file_extension(path) in _PDF_PREVIEW_EXTENSIONS
|
return _file_extension(path) in _PDF_PREVIEW_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
@@ -330,81 +330,32 @@ def _pdf_file_preview(
|
|||||||
max_bytes: int = _PDF_TEXT_BYTES,
|
max_bytes: int = _PDF_TEXT_BYTES,
|
||||||
max_pages: int = _PDF_TEXT_PAGES,
|
max_pages: int = _PDF_TEXT_PAGES,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
# Note: PDF text extraction reads only bounded, reasonably sized PDF files and extracts a limited number of pages for modal UX.
|
# Note: The modal keeps a metadata payload here, while the frontend streams the real PDF through the existing file download route in inline mode.
|
||||||
size = int(selected.get("size") or 0)
|
size = int(selected.get("size") or 0)
|
||||||
result = {
|
return {
|
||||||
**selected,
|
**selected,
|
||||||
"kind": "pdf",
|
"kind": "pdf",
|
||||||
"parser": "pypdf",
|
"parser": "browser-pdf-viewer",
|
||||||
"supported": True,
|
"supported": True,
|
||||||
"sample_bytes": 0,
|
"sample_bytes": 0,
|
||||||
"sample_limit": int(max_bytes),
|
"sample_limit": 0,
|
||||||
"page_limit": int(max_pages),
|
"page_limit": 0,
|
||||||
"partial": False,
|
"partial": False,
|
||||||
"summary": {},
|
|
||||||
"fields": [
|
|
||||||
{"key": "Type", "value": "PDF text preview"},
|
|
||||||
{"key": "Read limit", "value": human_size(max_bytes)},
|
|
||||||
{"key": "Page limit", "value": str(max_pages)},
|
|
||||||
],
|
|
||||||
"raw": [],
|
|
||||||
"text": "",
|
|
||||||
}
|
|
||||||
if size > max_bytes:
|
|
||||||
result.update({
|
|
||||||
"too_large": True,
|
|
||||||
"error": f"PDF text extraction is limited to {human_size(max_bytes)}. Download the file to read the full PDF.",
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
|
|
||||||
PdfReader = _pdf_imports()
|
|
||||||
data = _read_file_prefix(profile, remote_path, max_bytes)
|
|
||||||
result["sample_bytes"] = len(data)
|
|
||||||
try:
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
reader = PdfReader(BytesIO(data))
|
|
||||||
if getattr(reader, "is_encrypted", False):
|
|
||||||
try:
|
|
||||||
reader.decrypt("")
|
|
||||||
except Exception:
|
|
||||||
result.update({"error": "This PDF is encrypted and cannot be read without a password."})
|
|
||||||
return result
|
|
||||||
pages = list(reader.pages)
|
|
||||||
page_count = len(pages)
|
|
||||||
extracted = []
|
|
||||||
for page_number, page in enumerate(pages[:max_pages], start=1):
|
|
||||||
try:
|
|
||||||
page_text = page.extract_text() or ""
|
|
||||||
except Exception as exc:
|
|
||||||
page_text = f"[Page {page_number}: text extraction failed: {exc}]"
|
|
||||||
if page_text.strip():
|
|
||||||
extracted.append(f"--- Page {page_number} ---\n{page_text.strip()}")
|
|
||||||
text = "\n\n".join(extracted).strip()
|
|
||||||
result.update({
|
|
||||||
"text": text,
|
|
||||||
"page_count": page_count,
|
|
||||||
"extracted_pages": min(page_count, max_pages),
|
|
||||||
"partial": page_count > max_pages,
|
|
||||||
"summary": {
|
"summary": {
|
||||||
"duration": None,
|
"duration": None,
|
||||||
"bit_rate": human_size(size) if size else None,
|
"bit_rate": human_size(size) if size else None,
|
||||||
"compression": "PDF",
|
"compression": "PDF",
|
||||||
"producer": f"{min(page_count, max_pages)} / {page_count} page(s)",
|
"producer": "Browser inline preview",
|
||||||
"creation_date": None,
|
"creation_date": None,
|
||||||
},
|
},
|
||||||
"fields": result["fields"] + [
|
"fields": [
|
||||||
|
{"key": "Type", "value": "PDF inline preview"},
|
||||||
{"key": "PDF size", "value": human_size(size)},
|
{"key": "PDF size", "value": human_size(size)},
|
||||||
{"key": "Pages", "value": str(page_count)},
|
{"key": "Preview mode", "value": "Browser PDF renderer"},
|
||||||
{"key": "Extracted pages", "value": str(min(page_count, max_pages))},
|
|
||||||
],
|
],
|
||||||
})
|
"raw": [],
|
||||||
if not text:
|
"text": "",
|
||||||
result["error"] = "No readable text was found in the selected PDF pages. The file may be scanned or image-based."
|
}
|
||||||
return result
|
|
||||||
except Exception as exc:
|
|
||||||
result.update({"error": f"Unable to read PDF text: {exc}"})
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _media_info_temp_sample(profile: dict, source_path: str, max_bytes: int) -> tuple[str, int]:
|
def _media_info_temp_sample(profile: dict, source_path: str, max_bytes: int) -> tuple[str, int]:
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -4566,19 +4566,50 @@ body,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-info-pdf-preview {
|
.media-info-pdf-toolbar {
|
||||||
background: var(--bs-body-bg);
|
align-items: center;
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
border: 1px solid var(--bs-border-color);
|
border: 1px solid var(--bs-border-color);
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.9rem;
|
||||||
color: var(--bs-body-color);
|
display: flex;
|
||||||
font-family: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
gap: 1rem;
|
||||||
font-size: 0.95rem;
|
justify-content: space-between;
|
||||||
line-height: 1.5;
|
margin-bottom: 0.75rem;
|
||||||
margin: 0 0 1rem;
|
padding: 0.75rem 1rem;
|
||||||
max-height: 62vh;
|
}
|
||||||
overflow: auto;
|
|
||||||
padding: 1rem;
|
.media-info-pdf-toolbar b,
|
||||||
white-space: pre-wrap;
|
.media-info-pdf-toolbar span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-info-pdf-toolbar span {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-info-pdf-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-info-pdf-viewer {
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
height: min(72vh, 860px);
|
||||||
|
min-height: 520px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-info-pdf-viewer object {
|
||||||
|
border: 0;
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-info-pdf-empty {
|
.media-info-pdf-empty {
|
||||||
@@ -4618,4 +4649,18 @@ body,
|
|||||||
.media-info-pdf-empty {
|
.media-info-pdf-empty {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media-info-pdf-toolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-info-pdf-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-info-pdf-viewer {
|
||||||
|
height: 68vh;
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user