Moved tab bar to dropdown in TitleBar.svelte

Made image rendering dynamic
This commit is contained in:
Kilian Schuettler 2025-02-11 13:14:33 +01:00
parent d15aa2a4ed
commit 4b20d20783
21 changed files with 1906 additions and 427 deletions

1301
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
name = "pdf-forge"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
authors = ["Kilian Schuettler"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -22,7 +22,7 @@ tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
pdf = { path = "../src-pdfrs/pdf", features = ["cache"] }
pdf = { path = "/home/kschuettler/rust/pdf-forge/src-pdfrs/pdf", features = ["cache", "dump"], default-features=false}
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"
uuid = { version = "1.12.0", features = ["v4"] }
@ -31,3 +31,6 @@ lazy_static = "1.4.0"
fax = "0.2"
base64 = "0.21"
image = { version = "0.25.5", features = ["jpeg"] }
pdf_render = { path = "../../pdf-render/render" }
pathfinder_rasterize = { git = "https://github.com/s3bk/pathfinder_rasterizer" }
pathfinder_geometry = { git = "https://github.com/servo/pathfinder" }

View File

@ -1,3 +1,4 @@
mod render;
mod retrieval;
#[cfg(test)]
@ -6,24 +7,24 @@ mod tests;
extern crate pdf;
use crate::pdf::object::Resolve;
use crate::render::Renderer;
use base64;
use image::codecs::jpeg::JpegEncoder;
use image::ImageFormat;
use base64::prelude::BASE64_STANDARD;
use base64::Engine;
use image::{ImageFormat, RgbImage};
use lazy_static::lazy_static;
use pdf::file::{File, FileOptions, NoLog, ObjectCache, StreamCache};
use pdf::object::{ImageXObject, Object, PlainRef};
use pdf::object::{Object, PlainRef};
use pdf::primitive::{Dictionary, Primitive};
use pdf::xref::XRef;
use regex::Regex;
use retrieval::{
get_pdf_stream_by_path_with_file, get_prim_by_path_with_file, get_stream_data_by_path_with_file,
};
use retrieval::{get_prim_by_path_with_file, get_stream_data_by_path_with_file};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::io::Cursor;
use std::ops::DerefMut;
use std::path::Path;
use std::sync::{Arc, Mutex, MutexGuard, RwLock};
use std::sync::{Arc, RwLock};
use tauri::{Manager, State};
use uuid::Uuid;
@ -112,7 +113,11 @@ pub struct ContentsModel {
#[tauri::command]
fn get_all_files(session: State<Session>) -> Vec<PdfFile> {
session.get_all_files().iter().map(|s| s.pdf_file.clone()).collect()
session
.get_all_files()
.iter()
.map(|s| s.pdf_file.clone())
.collect()
}
#[tauri::command]
@ -180,12 +185,7 @@ fn to_pdf_file(path: &str, file: &CosFile) -> Result<PdfFile, String> {
}
#[tauri::command]
fn get_contents(
id: &str,
path: &str,
session: State<Session>,
) -> Result<ContentsModel, String> {
fn get_contents(id: &str, path: &str, session: State<Session>) -> Result<ContentsModel, String> {
let file = session.get_file(id)?;
let (_, page_prim, _) = get_prim_by_path_with_file(path, &file.cos_file)?;
@ -211,22 +211,12 @@ fn get_stream_data_as_string(
path: &str,
session: State<Session>,
) -> Result<String, String> {
let file = session.get_file(id)?;
let data = get_stream_data_by_path_with_file(path, &file.cos_file)?;
Ok(String::from_utf8_lossy(&data).into_owned())
}
#[tauri::command]
fn get_stream_data_as_image(
id: &str,
path: &str,
session: State<Session>,
) -> Result<String, String> {
use base64::prelude::*;
let file = session.get_file(id)?;
let img = retrieval::get_image_by_path(path, &file.cos_file)?;
fn encode_b64(img: RgbImage) -> Result<String, String> {
let mut writer = Cursor::new(Vec::new());
match img.write_to(&mut writer, ImageFormat::Jpeg) {
Ok(_) => Ok(format!(
@ -238,6 +228,23 @@ fn get_stream_data_as_image(
}
}
#[tauri::command]
async fn get_stream_data_as_image(id: &str, path: &str, session: State<'_, Session>,) -> Result<String, String> {
use base64::prelude::*;
let file = session.get_file(id)?;
let img = retrieval::get_image_by_path(path, &file.cos_file)?;
encode_b64(img.into_rgb8())
}
#[tauri::command]
async fn get_page_by_num(id: &str, num: u32, session: State<'_, Session>) -> Result<String, String> {
let file = session.get_file(id)?;
let mut renderer = Renderer::new(&file.cos_file, 150);
let img = renderer.render(num)?;
encode_b64(img.into_rgb8())
}
#[tauri::command]
fn get_prim_by_path(
id: &str,
@ -285,7 +292,6 @@ fn get_prim_tree_by_path(
paths: Vec<TreeViewRequest>,
session: State<Session>,
) -> Result<Vec<PrimitiveTreeView>, String> {
let file = session.get_file(id)?;
let results = paths
@ -632,7 +638,6 @@ impl PrimitiveTreeView {
}
#[tauri::command]
fn get_xref_table(id: &str, session: State<Session>) -> Result<XRefTableModel, String> {
let file = session.get_file(id)?;
get_xref_table_model_with_file(&file.cos_file)
}
@ -701,7 +706,6 @@ struct SessionFile {
cos_file: CosFile,
}
impl Session {
fn load() -> Session {
Session {
@ -711,15 +715,27 @@ impl Session {
fn get_file(&self, id: &str) -> Result<Arc<SessionFile>, String> {
let lock = self.files.read().unwrap();
lock.get(id).cloned().ok_or(format!(" File {} not found!", id))
lock.get(id)
.cloned()
.ok_or(format!(" File {} not found!", id))
}
fn get_all_files(&self) -> Vec<Arc<SessionFile>> {
self.files.read().unwrap().values().map(|f| f.clone()).collect()
self.files
.read()
.unwrap()
.values()
.map(|f| f.clone())
.collect()
}
fn get_all_file_ids(&self) -> Vec<String> {
self.files.read().unwrap().keys().map(|f| f.clone()).collect()
self.files
.read()
.unwrap()
.keys()
.map(|f| f.clone())
.collect()
}
fn handle_upload(&self, pdf_file: PdfFile, cos_file: CosFile) -> Result<(), String> {
@ -729,11 +745,11 @@ impl Session {
Arc::new(SessionFile {
pdf_file: pdf_file,
cos_file: cos_file,
},
)) {
}),
) {
Some(_) => Err("File could not be uploaded!".to_string()),
None => Ok(()),
}
};
};
Err("Lock could not be acquired!".to_string())
}
@ -764,7 +780,8 @@ pub fn run() {
get_xref_table,
get_contents,
get_stream_data_as_string,
get_stream_data_as_image
get_stream_data_as_image,
get_page_by_num
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

53
src-tauri/src/render.rs Normal file
View File

@ -0,0 +1,53 @@
extern crate pdf_render;
use crate::{t, CosFile};
use image::{DynamicImage, RgbaImage};
use pathfinder_geometry::transform2d::Transform2F;
use pathfinder_rasterize::Rasterizer;
use pdf_render::{render_page, Cache, SceneBackend};
pub struct Renderer<'a> {
file: &'a CosFile,
cache: Cache,
transform: Transform2F,
}
impl<'a> Renderer<'a> {
pub fn new(file: &'a CosFile, dpi: u32) -> Renderer<'a> {
Renderer {
file,
cache: Cache::new(),
transform: Transform2F::from_scale(dpi as f32 / 25.4),
}
}
pub fn render(&mut self, page_num: u32) -> Result<DynamicImage, String> {
let resolver = self.file.resolver();
let page = t!(self.file.get_page(page_num - 1));
let mut backend = SceneBackend::new(&mut self.cache);
t!(render_page(&mut backend, &resolver, &page, self.transform));
let scene = backend.finish();
let mut rasterizer = Rasterizer::new();
Ok(DynamicImage::ImageRgba8(rasterizer.rasterize(scene, None)))
}
}
pub fn render(file: &CosFile, page_num: u32) -> Result<RgbaImage, String> {
render_with_dpi(file, page_num, 300)
}
pub fn render_with_dpi(file: &CosFile, page_num: u32, dpi: u32) -> Result<RgbaImage, String> {
let resolver = file.resolver();
let page = t!(file.get_page(page_num));
let mut cache = Cache::new();
let mut backend = SceneBackend::new(&mut cache);
t!(render_page(
&mut backend,
&resolver,
&page,
Transform2F::from_scale(dpi as f32 / 25.4)
));
Ok(Rasterizer::new().rasterize(backend.finish(), None))
}

View File

@ -3,7 +3,7 @@ extern crate pdf;
#[cfg(test)]
mod tests {
use crate::retrieval::{get_image_by_path, get_pdf_stream_by_path_with_file};
use crate::retrieval::get_image_by_path;
use crate::{
get_prim_by_path_with_file, get_prim_model_by_path_with_file,
get_prim_tree_by_path_with_file, get_stream_data_by_path_with_file,
@ -11,14 +11,14 @@ mod tests {
};
use image::codecs::jpeg::JpegEncoder;
use image::imageops::FilterType;
use image::ImageFormat;
use image::{DynamicImage, ImageFormat, RgbaImage};
use pdf::content::{display_ops, serialize_ops, Op};
use pdf::file::FileOptions;
use pdf::object::{Object, ObjectWrite, Page, PlainRef, Resolve};
use pdf::primitive::Primitive;
use std::io::Cursor;
use std::time::Instant;
macro_rules! timed {
($func_call:expr, $label:expr) => {{
let start = std::time::Instant::now();
@ -29,14 +29,15 @@ mod tests {
}};
}
// Import items to be tested from the parent module
const FILE_PATH: &str =
const PDF_SPEC_PATH: &str =
"/home/kschuettler/Dokumente/Scientific Papers/PDF Specification/ISO_32000-2_2020(en).pdf";
const STUDY_402: &str = "/home/kschuettler/Dokumente/TestFiles/402Study.pdf";
#[test]
fn test_read_x_ref() {
let start = Instant::now();
let file = timed!(
FileOptions::cached().open(FILE_PATH).unwrap(),
FileOptions::cached().open(PDF_SPEC_PATH).unwrap(),
"Loading file"
);
let resolver = file.resolver();
@ -62,7 +63,7 @@ mod tests {
#[test]
fn test_read_tree() {
let file = timed!(
FileOptions::cached().open(FILE_PATH).unwrap(),
FileOptions::cached().open(PDF_SPEC_PATH).unwrap(),
"Loading file"
);
let mut path = Vec::new();
@ -116,9 +117,7 @@ mod tests {
#[test]
fn test_read_by_path() {
let file = timed!(
FileOptions::cached()
.open("/home/kschuettler/Dokumente/TestFiles/402Study.pdf")
.unwrap(),
FileOptions::cached().open(STUDY_402).unwrap(),
"Loading file"
);
let path = "/Page4/Resources/XObject/X13/Data";
@ -145,7 +144,7 @@ mod tests {
#[test]
fn test_read_trailer() {
let file = timed!(
FileOptions::cached().open(FILE_PATH).unwrap(),
FileOptions::cached().open(PDF_SPEC_PATH).unwrap(),
"Loading file"
);
let mut file2 = timed!(FileOptions::uncached().storage(), "Loading storage");
@ -166,18 +165,18 @@ mod tests {
fn test_read_pdf_file() {
use crate::to_pdf_file;
let file = timed!(
FileOptions::cached().open(FILE_PATH).unwrap(),
FileOptions::cached().open(PDF_SPEC_PATH).unwrap(),
"Loading file"
);
let _pdf_file = timed!(to_pdf_file(FILE_PATH, &file), "pages 1");
let pdf_file = timed!(to_pdf_file(FILE_PATH, &file), "pages 2");
let _pdf_file = timed!(to_pdf_file(PDF_SPEC_PATH, &file), "pages 1");
let pdf_file = timed!(to_pdf_file(PDF_SPEC_PATH, &file), "pages 2");
println!("{:?}", pdf_file);
}
#[test]
fn test_read_contents() {
let file = timed!(
FileOptions::cached().open(FILE_PATH).unwrap(),
FileOptions::cached().open(PDF_SPEC_PATH).unwrap(),
"Loading file"
);
@ -200,7 +199,7 @@ mod tests {
#[test]
fn test_read_stream() {
let file = timed!(
FileOptions::cached().open(FILE_PATH).unwrap(),
FileOptions::cached().open(PDF_SPEC_PATH).unwrap(),
"Loading file"
);
let prim = timed!(
@ -252,4 +251,29 @@ mod tests {
100.0 - ((size as f32 / bytes as f32) * 100.0)
);
}
use crate::render::{render, render_with_dpi, Renderer};
#[test]
fn test_render() {
let file = timed!(
FileOptions::cached().open(PDF_SPEC_PATH).unwrap(),
"Loading file"
);
let mut renderer = Renderer::new(&file, 150);
let mut rendered_pages: Vec<DynamicImage> = vec![];
timed!(
for i in 0..1 {
let img = timed!(renderer.render(i), format!("render page {}", i));
rendered_pages.push(img.unwrap())
},
"rendering some pages"
);
timed!(
for (num, image) in rendered_pages.iter().enumerate() {
image.to_rgb8().save(format!("page_{}.jpg", num)).unwrap()
},
"saving images"
);
}
}

View File

@ -1,44 +1,34 @@
<script lang="ts">
import WelcomeScreen from "./WelcomeScreen.svelte";
import {Pane, Splitpanes} from "svelte-splitpanes";
import {open} from "@tauri-apps/plugin-dialog";
import {invoke} from "@tauri-apps/api/core";
import { Pane, Splitpanes } from "svelte-splitpanes";
import { open } from "@tauri-apps/plugin-dialog";
import { invoke } from "@tauri-apps/api/core";
import type PdfFile from "../models/PdfFile";
import ToolbarLeft from "./ToolbarLeft.svelte";
import FileView from "./FileView.svelte";
import TabBar from "./TabBar.svelte";
import FileViewState from "../models/FileViewState.svelte";
import TitleBar from "./TitleBar.svelte";
import ToolbarRight from "./ToolbarRight.svelte";
import Footer from "./Footer.svelte";
import NotificationModal from "./NotificationModal.svelte";
const footerHeight: number = 30;
const titleBarHeight: number = 30;
const tabBarHeight: number = 30;
let files: PdfFile[] = $state([]);
let innerHeight: number = $state(1060);
let xrefTableShowing: boolean = $state(false);
let treeShowing: boolean = $state(true);
let notificationsShowing: boolean = $state(false);
let pagesShowing: boolean = $state(false);
let fileViewHeight: number = $derived(
Math.max(innerHeight - footerHeight - tabBarHeight - titleBarHeight, 0),
Math.max(innerHeight - footerHeight - titleBarHeight, 0),
);
let fStates: Map<string, FileViewState> = new Map<string, FileViewState>();
let fStates: FileViewState[] = $state([]);
let fState: FileViewState | undefined = $state();
let selected_file = $derived(fState ? fState.file : undefined);
initialLoadAllFiles();
function initialLoadAllFiles() {
invoke<PdfFile[]>("get_all_files")
.then((result_list) => {
files = result_list;
createFileStates(files);
if (files.length > 0) {
selectFile(files[files.length - 1]);
}
createFileStates(result_list);
fState = fStates[0];
})
.catch((error) => {
console.error("File retrieval failed: " + error);
@ -47,11 +37,12 @@
function createFileStates(files: PdfFile[]) {
for (let file of files) {
if (fStates.has(file.id)) {
let fState = fStates.find((state) => state.file.id === file.id);
if (fState) {
continue;
}
let fState = new FileViewState(file);
fStates.set(file.id, fState);
fState = new FileViewState(file);
fStates.push(fState);
}
}
@ -59,72 +50,63 @@
let file_path = await open({
multiple: false,
directory: false,
filters: [{name: "pdf", extensions: ["pdf"]}],
filters: [{ name: "pdf", extensions: ["pdf"] }],
});
if (file_path === null || Array.isArray(file_path)) {
return;
}
invoke<String>("upload", {path: file_path})
.then((result) => {
invoke<PdfFile[]>("get_all_files")
.then((result_list) => {
files = result_list;
createFileStates(files);
let file = files.find((file) => file.id === result);
if (file) {
selectFile(file);
}
})
.catch((error) => {
console.error("Fetch all files failed with: " + error);
});
})
.catch((error) => {
let uploadedId = await invoke<string>("upload", {
path: file_path,
}).catch((error) => {
console.error("File upload failed with: " + error);
});
if (!uploadedId) return;
let allFiles = await invoke<PdfFile[]>("get_all_files").catch(
(error) => {
console.error("Fetch all files failed with: " + error);
},
);
if (!allFiles) return;
createFileStates(allFiles);
fState = fStates.find((state) => state.file.id === uploadedId);
}
function selectFile(file: PdfFile) {
fState = fStates.get(file.id);
}
function closeFile(file: PdfFile) {
invoke("close_file", {id: file.id})
function closeFile(stateToClose: FileViewState) {
invoke("close_file", { id: stateToClose.file.id })
.then((_) => {
files = files.filter((f) => f.id != file.id);
if (file === selected_file) {
fState = undefined;
fStates = fStates.filter(
(state) => state.file.id !== stateToClose.file.id,
);
if (stateToClose.file.id === fState?.file.id) {
fState = fStates[0];
}
})
.catch((err) => console.error(err));
}
</script>
<svelte:window bind:innerHeight/>
<svelte:window bind:innerHeight />
<main style="height: {innerHeight}px; overflow: hidden;">
<div style="height: {titleBarHeight}px">
<TitleBar></TitleBar>
</div>
<div class="fileview_container" style="height: {fileViewHeight + 30}px">
<Splitpanes theme="forge-movable" dblClickSplitter={false}>
<Pane size={2.5} minSize={1.5} maxSize={4}>
<ToolbarLeft {fState}
></ToolbarLeft>
</Pane>
<Pane>
<TabBar
{files}
{selected_file}
<div style="height: {titleBarHeight}px; z-index: 100; position: relative;">
<TitleBar
{fStates}
{fState}
closeTab={closeFile}
openTab={upload}
selectTab={selectFile}
></TabBar>
selectTab={(state) => (fState = state)}
></TitleBar>
</div>
<div class="fileview_container" style="height: {fileViewHeight}px">
<Splitpanes theme="forge-movable" dblClickSplitter={false}>
<Pane size={2.5} minSize={1.5} maxSize={4}>
<ToolbarLeft {fState}></ToolbarLeft>
</Pane>
<Pane>
{#if fState}
<FileView
{fState}
height={fileViewHeight}
></FileView>
<FileView {fState} height={fileViewHeight}></FileView>
{:else}
<WelcomeScreen {upload}></WelcomeScreen>
{/if}

View File

@ -1,8 +1,6 @@
<script lang="ts">
import {invoke} from "@tauri-apps/api/core";
import type ContentModel from "../models/ContentModel.svelte";
import {onMount} from "svelte";
import * as monaco from "monaco-editor";
import type FileViewState from "../models/FileViewState.svelte";
import StreamEditor from "./StreamEditor.svelte";
@ -55,7 +53,7 @@
</script>
<StreamEditor stream_data={contents} height={height}></StreamEditor>
<style lang="postcss">
</style>

View File

@ -4,7 +4,7 @@
import PrimitiveView from "./PrimitiveView.svelte";
import TreeView from "./TreeView.svelte";
import type FileViewState from "../models/FileViewState.svelte";
import ContentsView from "./ContentsView.svelte";
import PageView from "./PageView.svelte";
import type {PathSelectedEvent} from "../events/PathSelectedEvent";
import {onMount} from "svelte";
import NotificationModal from "./NotificationModal.svelte";
@ -70,9 +70,7 @@
<PrimitiveView {fState} {height}></PrimitiveView>
{/if}
{#if fState.pageMode}
<div class="overflow-hidden">
<ContentsView {fState} {height}></ContentsView>
</div>
<PageView {fState} {height}></PageView>
{/if}
</div>
</Pane>

View File

@ -0,0 +1,46 @@
<script lang="ts">
import ContentsView from "./ContentsView.svelte";
import RenderedPageView from "./RenderedPageView.svelte";
import {Pane, Splitpanes} from "svelte-splitpanes";
import {PageViewState} from "../models/PageViewState.svelte";
import type FileViewState from "../models/FileViewState.svelte";
import StreamEditor from "./StreamEditor.svelte";
let {fState, height}: {fState: FileViewState, height: number} = $props();
let states: PageViewState[] = [];
let state: PageViewState | undefined = $state(undefined);
$effect(() => {
if (!(fState.container_prim && fState.container_prim.isPage())) return;
let page_num = fState.container_prim.getPageNumber();
if (state && state.file_id == fState.file.id && state.page_num === page_num) return;
state = states.find(s => s.file_id === fState.file.id && s.page_num == page_num)
if (!state) {
state = new PageViewState(fState.file.id, page_num, (m) => fState.logError(m));
states.push(state);
}
})
</script>
{#if fState.container_prim && fState.container_prim.isPage() && state}
<Splitpanes theme="forge-movable">
<Pane minSize={1}>
<div class="overflow-hidden">
<StreamEditor stream_data={state.contents} height={height - 1}></StreamEditor>
</div>
</Pane>
<Pane minSize={1}>
<RenderedPageView imgB64={state.img_data} {height}></RenderedPageView>
</Pane>
</Splitpanes>
{:else }
<h1>Please select a Page!</h1>
{/if}
<style lang="postcss">
</style>

View File

@ -0,0 +1,30 @@
<script lang="ts">
import ZoomableContainer from "./ZoomableContainer.svelte";
import { RefreshOutline } from "flowbite-svelte-icons";
let { imgB64, height }: { imgB64: string | undefined; height: number } =
$props();
</script>
<div class="page-container" style:height={height + "px"}>
{#if !imgB64}
<div class="loading-container">
<RefreshOutline class="animate-spin" size="xl" />
</div>
{:else}
<ZoomableContainer {imgB64} {height} />
{/if}
</div>
<style lang="postcss">
.page-container {
@apply w-full bg-forge-dark relative m-auto;
}
.image-container {
@apply flex items-center justify-center h-full;
width: fit-content;
}
.loading-container {
@apply flex items-center justify-center h-full;
}
</style>

View File

@ -2,17 +2,16 @@
import type FileViewState from "../models/FileViewState.svelte";
import StreamEditor from "./StreamEditor.svelte";
import type {StreamData} from "../models/StreamData";
import type {StreamData} from "../models/StreamData.svelte";
import RenderedPageView from "./RenderedPageView.svelte";
let {fState, editorHeight}: { fState: FileViewState, editorHeight: number } = $props()
let streamData: StreamData = $derived(fState.container_prim?.stream_data ?);
let streamData: StreamData = $derived(fState.container_prim?.stream_data?);
</script>
{#if streamData.type === "Image"}
<div class="w-full m-auto">
<img alt="x-object" src={streamData.data}/>
</div>
<RenderedPageView imgB64={streamData.data} height={editorHeight}></RenderedPageView>
{:else}
<StreamEditor
stream_data={streamData.data}

View File

@ -13,7 +13,6 @@
let { stream_data, height }: { stream_data: string; height: number } =
$props();
let contents: string | undefined = $state(undefined);
let editorContainer: HTMLElement;
let editor: monaco.editor.IStandaloneCodeEditor;
onMount(() => {
@ -43,7 +42,7 @@
}
</script>
<div bind:this={editorContainer} style="height: {height}px; width: 100%;"></div>
<div bind:this={editorContainer} style:height = {height - 1 + "px"}></div>
<style lang="postcss">
</style>

View File

@ -1,8 +1,13 @@
<script lang="ts">
import {PlusOutline, CloseOutline} from "flowbite-svelte-icons";
import type PdfFile from "../models/PdfFile";
import type FileViewState from "../models/FileViewState.svelte";
let {files, selected_file, selectTab, closeTab, openTab}: { files: PdfFile[], selected_file: PdfFile | undefined, selectTab: any, closeTab: any, openTab: any } = $props();
let {fStates, fState, selectTab, closeTab, openTab}:
{ fStates: FileViewState[],
fState: FileViewState | undefined,
selectTab: (arg0: FileViewState) => void,
closeTab: (arg0: FileViewState) => void,
openTab: () => void } = $props();
function formatFileName(name: string) {
if (name.length < 20) {
@ -12,15 +17,15 @@
}
</script>
<div class="tab-bar">
{#each files as file}
<div class={file.id === selected_file.id ? "tab-outer bg-forge-prim border-b-2 border-forge-acc" : "tab-outer bg-forge-dark"}>
{#each fStates as state}
<div class={state.file.id === fState?.file.id ? "tab-outer bg-forge-prim border-b-2 border-forge-acc" : "tab-outer bg-forge-dark"}>
<div class="tab-text">
<button onclick={() => selectTab(file)} class="tab-click">
{formatFileName(file.name)}
<button onclick={() => selectTab(state)} class="tab-click">
{formatFileName(state.file.name)}
</button>
</div>
<div class="justify-center flex">
<button onclick={() => closeTab(file)} class="rounded-md hover:bg-forge-active">
<button onclick={() => closeTab(state)} class="rounded-md hover:bg-forge-active">
<CloseOutline size="xs" color="custom"/>
</button>
</div>

View File

@ -1,31 +1,186 @@
<script lang="ts">
import {CloseOutline, MinusOutline, WindowRestoreSolid} from 'flowbite-svelte-icons'
import {getCurrentWindow} from '@tauri-apps/api/window';
import {
CloseOutline,
MinusOutline,
WindowRestoreSolid,
CaretDownOutline,
PlusOutline,
FilePdfSolid,
} from "flowbite-svelte-icons";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { homeDir } from "@tauri-apps/api/path";
import type FileViewState from "../models/FileViewState.svelte";
const appWindow = getCurrentWindow();
let home = "";
getHome();
let {
fStates,
fState,
selectTab,
closeTab,
openTab: newFile,
}: {
fStates: FileViewState[];
fState: FileViewState | undefined;
selectTab: (arg0: FileViewState) => void;
closeTab: (arg0: FileViewState) => void;
openTab: () => void;
} = $props();
let dropdownVisible = $state(false);
let dropdownEl: HTMLElement;
async function getHome() {
home = await homeDir();
}
function formatFileName(name: string) {
if (name.length < 20) {
return name;
}
return name.substring(0, 17) + "...";
}
function formatFilePath(path: string) {
// Replace home directory with ~
if (path.startsWith(home)) {
path = path.replace(home, "~");
}
if (path.length < 40) {
return path;
}
const parts = path.split(/[/\\]/);
const first = parts[0];
const last = parts[parts.length - 1];
const middle = parts
.slice(1, -1)
.map((part) => part.charAt(0) + "...")
.join("/");
return `${first}/${middle}/${last}`;
}
function minimize() {
appWindow.minimize();
}
function maximize() {
appWindow.toggleMaximize()
appWindow.toggleMaximize();
}
function close() {
appWindow.close()
appWindow.close();
}
function toggleDropdown() {
dropdownVisible = !dropdownVisible;
}
function handleFocusOut(e: FocusEvent) {
const dropdown = dropdownEl;
if (!dropdown) return;
const relatedTarget = e.relatedTarget as HTMLElement;
if (!dropdown.contains(relatedTarget)) {
dropdownVisible = false;
}
}
function handleSelectTab(state: FileViewState) {
selectTab(state);
dropdownVisible = false;
}
function handleNewFile() {
newFile();
dropdownVisible = false;
}
function handleCloseTab(state: FileViewState) {
closeTab(state);
}
</script>
<div data-tauri-drag-region class="titlebar">
<div class="m-[2px]">
<img src="/pdf-forge-logo-30x30.png" class="logo forge" alt="PDF Forge Logo"/>
<img
src="/pdf-forge-logo-30x30.png"
class="logo forge"
alt="PDF Forge Logo"
/>
</div>
<div class="titlebar-button-group">
<button onclick={minimize} class="titlebar-button" id="titlebar-minimize">
<MinusOutline/>
<div class="file-selector" onfocusout={handleFocusOut}>
<button class="file-dropdown-button" onclick={toggleDropdown}>
<span
>{fState
? formatFileName(fState.file.name)
: "Select File"}</span
>
<CaretDownOutline class="ml-1" size="xs" />
</button>
<button onclick={maximize} class="titlebar-button" id="titlebar-maximize">
<WindowRestoreSolid/>
{#if dropdownVisible}
<div class="dropdown-menu" bind:this={dropdownEl}>
<button class="dropdown-item new-tab" onclick={handleNewFile}>
<PlusOutline size="sm" />
<p class="ml-1">New File...</p>
</button>
<div class="divider"></div>
<div class="w-full text-forge-text_sec text-xs ml-3 mt-1 mb-1">
<p>Open Files</p>
</div>
<div class="divider"></div>
{#each fStates as state}
<div
class="dropdown-item justify-between"
class:active={state.file.id === fState?.file.id}
>
<button
class="select-button text-left"
onclick={() => handleSelectTab(state)}
>
<FilePdfSolid size="md" />
<div class="flex flex-col">
<p class="ml-1">
{formatFileName(state.file.name)}
</p>
<p class="path-text">
{formatFilePath(state.file.path)}
</p>
</div>
</button>
<button
class="close-button"
onclick={() => handleCloseTab(state)}
>
<CloseOutline size="xs" />
</button>
</div>
{/each}
</div>
{/if}
</div>
<div class="titlebar-button-group">
<button
onclick={minimize}
class="titlebar-button"
id="titlebar-minimize"
>
<MinusOutline />
</button>
<button
onclick={maximize}
class="titlebar-button"
id="titlebar-maximize"
>
<WindowRestoreSolid />
</button>
<button onclick={close} class="titlebar-button" id="titlebar-close">
<CloseOutline></CloseOutline>
@ -64,4 +219,56 @@
-webkit-user-select: none;
}
.file-selector {
@apply relative ml-10;
z-index: 100;
}
.file-dropdown-button {
@apply flex flex-row items-center px-2 py-1 text-xs text-forge-text rounded hover:bg-forge-acc;
height: 26px;
margin-top: 2px;
}
.dropdown-menu {
@apply absolute top-full left-0 mt-1 bg-forge-prim border border-forge-sec rounded-md border;
min-width: 200px;
}
.dropdown-item {
@apply flex flex-row items-center w-full text-xs text-forge-text hover:bg-forge-acc;
}
.dropdown-item.active {
@apply bg-forge-active hover:bg-forge-acc;
}
.close-button {
@apply ml-2 p-1 rounded hover:bg-forge-active;
opacity: 0;
}
.dropdown-item:hover .close-button {
opacity: 1;
}
.new-tab {
@apply border-t border-forge-bound px-3 py-3;
}
.select-button {
@apply m-0 px-2 py-1 flex flex-row;
}
.path-text {
@apply text-forge-text_sec ml-2 mt-1;
max-width: 100%;
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.divider {
@apply h-[1px] bg-forge-sec w-full;
}
</style>

View File

@ -28,7 +28,6 @@
let stickies: TreeViewModel[] = $state([]);
$effect(() => {
console.log("Effect triggered")
treeState = states.find((state) => state.file_id === file_id);
updateTreeView();
});
@ -36,7 +35,6 @@
async function updateTreeView() {
if (!treeState || treeState.file_id !== file_id) {
console.log("New State")
let newState = new TreeViewState(file_id, fState);
await newState.loadTreeView();
states.push(newState);

View File

@ -0,0 +1,160 @@
<script lang="ts">
import ZoomControls from "../components/ZoomControls.svelte";
type ZoomableProps = {
minZoom?: number;
maxZoom?: number;
zoomStep?: number;
imgB64: string;
height?: string | number;
};
let {
minZoom = 0.5,
maxZoom = 4,
zoomStep = 0.1,
imgB64,
height,
}: ZoomableProps = $props();
let scale = $state(1);
// DOM refs
let container = $state<HTMLElement>();
let image = $state<HTMLElement>();
// Drag state
type DragState = {
startX: number;
startY: number;
scrollLeft: number;
scrollTop: number;
};
let dragState: DragState | undefined = $state({
startX: 0,
startY: 0,
scrollLeft: 0,
scrollTop: 0,
});
// Zoom handlers
function handleZoom(event: WheelEvent) {
if (!container || !image || !event.ctrlKey) return;
event.preventDefault();
const delta = -Math.sign(event.deltaY);
const newScale = Math.min(
Math.max(scale + delta * zoomStep, minZoom),
maxZoom,
);
if (newScale !== scale) {
const rect = image.getBoundingClientRect();
const x = event.clientX;
const y = event.clientY;
const scaleChange = newScale / scale;
container.scrollLeft = x * scaleChange - x + container.scrollLeft;
container.scrollTop = y * scaleChange - y + container.scrollTop;
scale = newScale;
}
}
// Drag handlers
function handleDragStart(event: MouseEvent) {
if (!container) return;
event.preventDefault();
dragState = {
startX: event.pageX - container.offsetLeft,
startY: event.pageY - container.offsetTop,
scrollLeft: container.scrollLeft,
scrollTop: container.scrollTop,
};
}
function handleDragMove(event: MouseEvent) {
if (!container || !dragState) return;
event.preventDefault();
const x = event.pageX - container.offsetLeft;
const y = event.pageY - container.offsetTop;
container.scrollLeft = dragState.scrollLeft - (x - dragState.startX);
container.scrollTop = dragState.scrollTop - (y - dragState.startY);
}
function handleDragEnd(event: MouseEvent) {
event.preventDefault();
dragState = undefined;
}
function resetZoom() {
scale = 1;
if (container) {
container.scrollLeft = 0;
container.scrollTop = 0;
}
}
// Event listener management
$effect(() => {
if (container) {
container.addEventListener("wheel", handleZoom, { passive: false });
return () => container.removeEventListener("wheel", handleZoom);
}
});
</script>
<div class="relative w-full h-full">
<ZoomControls
{scale}
onZoomIn={() => (scale = Math.min(scale + zoomStep, maxZoom))}
onZoomOut={() => (scale = Math.max(scale - zoomStep, minZoom))}
{resetZoom}
/>
<div
class="container"
bind:this={container}
onmousedown={handleDragStart}
onmousemove={handleDragMove}
onmouseup={handleDragEnd}
onmouseleave={handleDragEnd}
role="presentation"
>
<div
class="image-container"
bind:this={image}
style:transform="scale({scale})"
style:height={height + "px"}
>
<img
alt="rendered-page"
src={imgB64}
style:max-height={height + "px"}
/>
</div>
</div>
</div>
<style lang="postcss">
.container {
@apply w-full h-full bg-forge-dark;
overflow: auto;
cursor: grab;
}
.container:active {
cursor: grabbing;
}
.image-container {
transform-origin: 0 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
</style>

View File

@ -1,20 +1,20 @@
import type PdfFile from "./PdfFile";
import type XRefEntry from "./XRefEntry";
import Primitive from "./Primitive.svelte";
import {invoke} from "@tauri-apps/api/core";
import { invoke } from "@tauri-apps/api/core";
import type XRefTable from "./XRefTable";
import type {PathSelectedEvent} from "../events/PathSelectedEvent";
import type {PrimitiveModel} from "./PrimitiveModel";
import {StreamData} from "./StreamData";
import {ForgeNotification} from "./ForgeNotification.svelte";
import {Mutex} from 'async-mutex';
import type { PathSelectedEvent } from "../events/PathSelectedEvent";
import type { PrimitiveModel } from "./PrimitiveModel";
import { StreamData } from "./StreamData.svelte";
import { ForgeNotification } from "./ForgeNotification.svelte";
import { Mutex } from 'async-mutex';
export default class FileViewState {
public file: PdfFile;
public treeMode: boolean = $state(true);
public pageMode: boolean = $state(false);
public xRefShowing: boolean = $state(true);
public xRefShowing: boolean = $state(false);
public notificationsShowing: boolean = $state(false);
@ -31,6 +31,7 @@ export default class FileViewState {
this.file = file;
this.selectPath(this.path);
this.loadXrefEntries()
this.notificationMutex = new Mutex();
}
getLastJump(): string | number | undefined {
@ -41,14 +42,15 @@ export default class FileViewState {
return this.container_prim?.getFirstJump()
}
public async logError(message: string) {
const release = await this.notificationMutex.acquire();
public logError(message: string) {
this.notificationMutex.acquire().then(release => {
try {
console.error(message)
this.notifications.push(new ForgeNotification(Date.now(), "ERROR", message));
} finally {
release(); // Always release the lock
release();
}
});
}
public async deleteNotification(timestamp: number) {
@ -70,7 +72,7 @@ export default class FileViewState {
}
public loadXrefEntries() {
invoke<XRefTable>("get_xref_table", {id: this.file.id})
invoke<XRefTable>("get_xref_table", { id: this.file.id })
.then(result => {
this.xref_entries = result.entries;
})
@ -105,16 +107,17 @@ export default class FileViewState {
leaf.isChildOf(container)
) {
const path = this.formatPaths(this.path) + "/Data";
container.stream_data = new StreamData(leaf.sub_type)
if (leaf.sub_type === "Image") {
const data = await invoke<string>("get_stream_data_as_image", {id: this.file.id, path: path})
const data = await invoke<string>("get_stream_data_as_image", { id: this.file.id, path: path })
.catch(err => this.logError(err))
if (!data) return;
container.stream_data = new StreamData(leaf.sub_type, data);
container.stream_data.setData(data);
} else {
const data = await invoke<string>("get_stream_data_as_string", {id: this.file.id, path: path})
const data = await invoke<string>("get_stream_data_as_string", { id: this.file.id, path: path })
.catch(err => this.logError(err))
if (!data) return;
container.stream_data = new StreamData(leaf.sub_type, data);
container.stream_data.setData(data);
}
}
}

View File

@ -0,0 +1,61 @@
import type ContentModel from "./ContentModel.svelte";
import {invoke} from "@tauri-apps/api/core";
export class PageViewState {
file_id: string;
page_num: number;
errorHandler: (arg0: string) => void;
img_data: string | undefined = $state();
contents: string = $state("");
constructor(file_id: string, page_num: number, errorHandler: (arg0: string) => void) {
this.file_id = file_id;
this.page_num = page_num;
this.errorHandler = errorHandler;
this.load();
}
public async load() {
invoke<ContentModel>("get_contents", {id: this.file_id, path: "Page" + this.page_num})
.then((result) => {
this.contents = this.mapToString(result);
this.loadImage();
})
.catch((err) => this.errorHandler(err));
}
public async loadImage() {
let result = await invoke<string>("get_page_by_num", {
id: this.file_id,
num: this.page_num,
}).catch(err => this.errorHandler(err));
if (result) {
this.img_data = result;
}
}
private mapToString(model: ContentModel | undefined): string {
if (!model) return "";
let text = "";
if (model.parts.length > 1) {
let i = 0;
for (let part of model.parts) {
text +=
"%----------------% Contents[" +
i +
"] %--------------%\n\n";
for (let line of part) {
text += " " + line + "\n";
}
text +=
"\n%-------------------% EOF %-------------------%\n\n";
i++;
}
} else {
text = model.parts[0].join("\n");
}
return text;
}
}

View File

@ -1,6 +1,6 @@
import {tracesAreEqual, tracesAreEqual2} from "../utils";
import type {PrimitiveModel} from "./PrimitiveModel";
import type {StreamData} from "./StreamData";
import type {StreamData} from "./StreamData.svelte";
export default class Primitive {
// input from api

View File

@ -0,0 +1,12 @@
export class StreamData {
// Image, Text
public type: string;
public data: string = $state("");
constructor(type: string) {
this.type = type;
}
public setData(data: string) {
this.data = data;
}
}

View File

@ -1,9 +0,0 @@
export class StreamData {
// Image, Text
public type: string;
public data: string;
constructor(type: string, value: string) {
this.type = type;
this.data = value;
}
}