image display basic functionality

This commit is contained in:
Tim Bendt
2025-05-16 17:17:37 -06:00
parent fc57e201a2
commit bec09bade8
7 changed files with 1187 additions and 117 deletions

View File

@@ -1,55 +1,199 @@
import os
import io
import asyncio
import os
import tempfile
from typing import Optional, Tuple, Set
from pathlib import Path
from typing import ByteString
import aiohttp
import mammoth
from docx import Document
from textual_image.renderable import Image
from openai import OpenAI
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Container, ScrollableContainer, Horizontal, Vertical
from textual.containers import Container, ScrollableContainer, Horizontal
from textual.screen import Screen
from textual.widgets import Label, Markdown, LoadingIndicator, Button, Footer
from textual.worker import Worker, get_current_worker
from textual import work
from textual.reactive import Reactive, reactive
from textual.widgets import Label, Markdown, Button, Footer, Static
from textual import work
from textual.reactive import reactive
from PIL import Image as PILImage
# Define convertible formats
PDF_CONVERTIBLE_FORMATS = {
"doc", "docx", "epub", "eml", "htm", "html", "md", "msg", "odp",
"ods", "odt", "pps", "ppsx", "ppt", "pptx", "rtf", "tif", "tiff",
"xls", "xlsm", "xlsx"
"doc",
"docx",
"epub",
"eml",
"htm",
"html",
"md",
"msg",
"odp",
"ods",
"odt",
"pps",
"ppsx",
"ppt",
"pptx",
"rtf",
"tif",
"tiff",
"xls",
"xlsm",
"xlsx",
}
JPG_CONVERTIBLE_FORMATS = {
"3g2", "3gp", "3gp2", "3gpp", "3mf", "ai", "arw", "asf", "avi",
"bas", "bash", "bat", "bmp", "c", "cbl", "cmd", "cool", "cpp",
"cr2", "crw", "cs", "css", "csv", "cur", "dcm", "dcm30", "dic",
"dicm", "dicom", "dng", "doc", "docx", "dwg", "eml", "epi", "eps",
"epsf", "epsi", "epub", "erf", "fbx", "fppx", "gif", "glb", "h",
"hcp", "heic", "heif", "htm", "html", "ico", "icon", "java", "jfif",
"jpeg", "jpg", "js", "json", "key", "log", "m2ts", "m4a", "m4v",
"markdown", "md", "mef", "mov", "movie", "mp3", "mp4", "mp4v", "mrw",
"msg", "mts", "nef", "nrw", "numbers", "obj", "odp", "odt", "ogg",
"orf", "pages", "pano", "pdf", "pef", "php", "pict", "pl", "ply",
"png", "pot", "potm", "potx", "pps", "ppsx", "ppsxm", "ppt", "pptm",
"pptx", "ps", "ps1", "psb", "psd", "py", "raw", "rb", "rtf", "rw1",
"rw2", "sh", "sketch", "sql", "sr2", "stl", "tif", "tiff", "ts",
"txt", "vb", "webm", "wma", "wmv", "xaml", "xbm", "xcf", "xd", "xml",
"xpm", "yaml", "yml"
"3g2",
"3gp",
"3gp2",
"3gpp",
"3mf",
"ai",
"arw",
"asf",
"avi",
"bas",
"bash",
"bat",
"bmp",
"c",
"cbl",
"cmd",
"cool",
"cpp",
"cr2",
"crw",
"cs",
"css",
"csv",
"cur",
"dcm",
"dcm30",
"dic",
"dicm",
"dicom",
"dng",
"doc",
"docx",
"dwg",
"eml",
"epi",
"eps",
"epsf",
"epsi",
"epub",
"erf",
"fbx",
"fppx",
"gif",
"glb",
"h",
"hcp",
"heic",
"heif",
"htm",
"html",
"ico",
"icon",
"java",
"jfif",
"jpeg",
"jpg",
"js",
"json",
"key",
"log",
"m2ts",
"m4a",
"m4v",
"markdown",
"md",
"mef",
"mov",
"movie",
"mp3",
"mp4",
"mp4v",
"mrw",
"msg",
"mts",
"nef",
"nrw",
"numbers",
"obj",
"odp",
"odt",
"ogg",
"orf",
"pages",
"pano",
"pdf",
"pef",
"php",
"pict",
"pl",
"ply",
"png",
"pot",
"potm",
"potx",
"pps",
"ppsx",
"ppsxm",
"ppt",
"pptm",
"pptx",
"ps",
"ps1",
"psb",
"psd",
"py",
"raw",
"rb",
"rtf",
"rw1",
"rw2",
"sh",
"sketch",
"sql",
"sr2",
"stl",
"tif",
"tiff",
"ts",
"txt",
"vb",
"webm",
"wma",
"wmv",
"xaml",
"xbm",
"xcf",
"xd",
"xml",
"xpm",
"yaml",
"yml",
}
# Enum for display modes
class DisplayMode:
IMAGE = "image"
TEXT = "text"
MARKDOWN = "markdown"
class DocumentViewerScreen(Screen):
"""Screen for viewing document content from OneDrive items."""
web_url: Reactive[str] = reactive("")
download_url: Reactive[str] = reactive("")
web_url = reactive("")
download_url = reactive("")
use_markitdown = True
image_bytes: ByteString = b""
BINDINGS = [
Binding("escape", "close", "Close"),
Binding("q", "close", "Close"),
@@ -73,37 +217,42 @@ class DocumentViewerScreen(Screen):
self.access_token = access_token
self.document_content = ""
self.plain_text_content = ""
self.is_markdown_mode = False
self.content_type = None
self.raw_content = None
self.file_extension = Path(item_name).suffix.lower().lstrip('.')
self.file_extension = Path(item_name).suffix.lower().lstrip(".")
self.mode: DisplayMode = DisplayMode.TEXT
def compose(self) -> ComposeResult:
"""Compose the document viewer screen."""
yield Container(
Horizontal(
Container(Button("", id="close_button"), id="button_container"),
Container(
Button("", id="close_button"),
id="button_container"
Label(f"Viewing: {self.item_name}", id="document_title"),
Label(
f'[link="{self.web_url}"]Open on Web[/link] | [link="{self.download_url}"]Download File[/link]',
id="document_link",
),
),
Container(
Label(f"Viewing: {self.item_name}", id="document_title"),
Label(f'[link="{self.web_url}"]Open on Web[/link] | [link="{self.download_url}"]Download File[/link]', id="document_link"),
),
id="top_container"
id="top_container",
),
ScrollableContainer(
Markdown("", id="markdown_content"),
Static(
"",
id="image_content",
expand=True,
),
Label("", id="plaintext_content", classes="hidden", markup=False),
id="content_container",
),
id="document_viewer"
id="document_viewer",
)
yield Footer()
def on_mount(self) -> None:
"""Handle screen mount event."""
self.query_one("#content_container").focus()
self.download_document()
@@ -116,11 +265,12 @@ class DocumentViewerScreen(Screen):
elif event.button.id == "export_button":
self.action_export_and_open()
def is_convertible_format(self) -> bool:
"""Check if the current file is convertible to PDF or JPG."""
return (self.file_extension in PDF_CONVERTIBLE_FORMATS or
self.file_extension in JPG_CONVERTIBLE_FORMATS)
return (
self.file_extension in PDF_CONVERTIBLE_FORMATS
or self.file_extension in JPG_CONVERTIBLE_FORMATS
)
def get_conversion_format(self) -> str:
"""Get the appropriate conversion format (pdf or jpg) for the current file."""
@@ -128,7 +278,7 @@ class DocumentViewerScreen(Screen):
return "pdf"
elif self.file_extension in JPG_CONVERTIBLE_FORMATS:
return "jpg"
return None
return ""
@work
async def download_document(self) -> None:
@@ -141,16 +291,20 @@ class DocumentViewerScreen(Screen):
async with session.get(metadataUrl, headers=headers) as response:
if response.status != 200:
error_text = await response.text()
self.notify(f"Failed to fetch document metadata: {error_text}", severity="error")
self.notify(
f"Failed to fetch document metadata: {error_text}",
severity="error",
)
return
metadata = await response.json()
self.item_name = metadata.get("name", self.item_name)
self.file_extension = Path(self.item_name).suffix.lower().lstrip('.')
self.file_extension = (
Path(self.item_name).suffix.lower().lstrip(".")
)
self.download_url = metadata.get("@microsoft.graph.downloadUrl", "")
self.web_url = metadata.get("webUrl", "")
except Exception as e:
self.notify(f"Error downloading document: {str(e)}", severity="error")
@@ -160,12 +314,14 @@ class DocumentViewerScreen(Screen):
# Show loading indicator
self.query_one("#content_container").loading = True
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers) as response:
if response.status != 200:
error_text = await response.text()
self.notify(f"Failed to download document: {error_text}", severity="error")
self.notify(
f"Failed to download document: {error_text}",
severity="error",
)
return
self.content_type = response.headers.get("content-type", "")
@@ -187,18 +343,82 @@ class DocumentViewerScreen(Screen):
return
try:
# Check for Office document types
if self.content_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
# Process as DOCX
if self.content_type.startswith("image/"):
from PIL import Image as PILImage
from io import BytesIO
self.notify("Attempting to display image in terminal")
if self.raw_content and len(self.raw_content) > 0:
self.image_bytes = self.raw_content
self.mode = DisplayMode.IMAGE
# Decode the image using BytesIO and Pillow
img = PILImage.open(BytesIO(self.image_bytes))
# Convert the image to RGB mode if it's not already
if img.mode != "RGB":
img = img.convert("RGB")
# Create a Textual Image renderable
textual_img = Image(img)
textual_img.expand = True
textual_img.width = 120
self.query_one("#image_content", Static).update(textual_img)
self.update_content_display()
return
except Exception as e:
self.notify(
f"Error displaying image in terminal: {str(e)}", severity="error"
)
try:
if self.use_markitdown:
self.notify(
"Attempting to convert file into Markdown with Markitdown...",
title="This could take a moment",
severity="info",
)
from markitdown import MarkItDown
with tempfile.NamedTemporaryFile(
suffix=f".{self.file_extension}", delete=False
) as temp_file:
temp_file.write(self.raw_content)
temp_path = temp_file.name
client = OpenAI()
md = MarkItDown(
enable_plugins=True, llm_client=client, llm_model="gpt-4o"
) # Set to True to enable plugins
result = md.convert(
temp_path,
)
self.mode = DisplayMode.MARKDOWN
self.document_content = result.markdown
self.plain_text_content = result.text_content
self.update_content_display()
return
except Exception as e:
self.notify(f"Error using MarkItDown: {str(e)}", severity="error")
try:
if (
self.content_type
== "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
):
self.notify(
"Processing DOCX file into Markdown using Mammoth...",
severity="info",
)
self.process_docx()
elif self.content_type.startswith("text/"):
# Process as plain text
text_content = self.raw_content.decode("utf-8", errors="replace")
self.document_content = text_content
self.mode = DisplayMode.TEXT
self.update_content_display()
elif self.content_type.startswith("image/"):
# For images, just display a message
self.document_content = f"*Image file: {self.item_name}*\n\nUse the 'Open URL' command to view this image in your browser."
self.mode = DisplayMode.MARKDOWN
self.update_content_display()
else:
# For other types, display a generic message
@@ -207,7 +427,7 @@ class DocumentViewerScreen(Screen):
conversion_format = self.get_conversion_format()
conversion_info = f"\n\nThis file can be converted to {conversion_format.upper()}. Press 'e' or click 'Export & Open' to convert and view."
self.document_content = f"*File: {self.item_name}*\n\nContent type: {self.content_type}{conversion_info}\n\nThis file type cannot be displayed directly in the viewer. You could [open in your browser]({self.web_url}), or [download the file]({self.download_url})."
self.is_markdown_mode = True
self.mode = DisplayMode.MARKDOWN
self.update_content_display()
except Exception as e:
self.notify(f"Error processing content: {str(e)}", severity="error")
@@ -228,7 +448,9 @@ class DocumentViewerScreen(Screen):
# Read the document structure with python-docx for plain text
doc = Document(temp_path)
self.plain_text_content = "\n\n".join([para.text for para in doc.paragraphs if para.text])
self.plain_text_content = "\n\n".join(
[para.text for para in doc.paragraphs if para.text]
)
self.document_content = markdown_text
# Clean up temporary file
@@ -243,14 +465,20 @@ class DocumentViewerScreen(Screen):
"""Update the content display with the processed document content."""
markdown_widget = self.query_one("#markdown_content", Markdown)
plaintext_widget = self.query_one("#plaintext_content", Label)
if self.is_markdown_mode:
image_widget = self.query_one("#image_content", Static)
if self.mode == DisplayMode.IMAGE:
image_widget.remove_class("hidden")
markdown_widget.add_class("hidden")
plaintext_widget.add_class("hidden")
elif self.mode == DisplayMode.MARKDOWN:
markdown_widget.update(self.document_content)
markdown_widget.remove_class("hidden")
image_widget.add_class("hidden")
plaintext_widget.add_class("hidden")
else:
plaintext_widget.update(self.plain_text_content)
plaintext_widget.remove_class("hidden")
image_widget.add_class("hidden")
markdown_widget.add_class("hidden")
@work
@@ -275,21 +503,29 @@ class DocumentViewerScreen(Screen):
async with session.get(url, headers=headers) as response:
if response.status != 200:
error_text = await response.text()
self.notify(f"Failed to export document: {error_text}", severity="error")
self.notify(
f"Failed to export document: {error_text}", severity="error"
)
return
converted_content = await response.read()
# Create temporary file with the right extension
file_name = f"{os.path.splitext(self.item_name)[0]}.{conversion_format}"
with tempfile.NamedTemporaryFile(suffix=f".{conversion_format}",
delete=False,
prefix=f"onedrive_export_") as temp_file:
file_name = (
f"{os.path.splitext(self.item_name)[0]}.{conversion_format}"
)
with tempfile.NamedTemporaryFile(
suffix=f".{conversion_format}",
delete=False,
prefix=f"onedrive_export_",
) as temp_file:
temp_file.write(converted_content)
temp_path = temp_file.name
# Open the file using the system default application
self.notify(f"Opening exported {conversion_format.upper()} file: {file_name}")
self.notify(
f"Opening exported {conversion_format.upper()} file: {file_name}"
)
self.app.open_url(f"file://{temp_path}")
self.query_one("#content_container").loading = False
@@ -299,9 +535,13 @@ class DocumentViewerScreen(Screen):
async def action_toggle_mode(self) -> None:
"""Toggle between Markdown and plaintext display modes."""
self.notify("Switching Modes", severity="info")
self.is_markdown_mode = not self.is_markdown_mode
self.mode = (
DisplayMode.MARKDOWN
if self.mode != DisplayMode.MARKDOWN
else DisplayMode.TEXT
)
self.update_content_display()
mode_name = "Markdown" if self.is_markdown_mode else "Plain Text"
mode_name = self.mode.name.capitalize()
self.notify(f"Switched to {mode_name} mode")
async def action_export_and_open(self) -> None: