Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ action [here](https://structure-vision.streamlit.app/).
- Interactive zoom controls with multiple zoom options
- Configurable alignment of the PDF viewer within its container
- Optional horizontal separators between PDF pages
- Maximization toggle for expanded viewing within the component boundaries
- Version 0.0.x provides an additional "legacy" viewer using the native pdf.js browser's, with limitations including no
annotations, no scrolling - this is removed from version 0.1.x.

Expand Down Expand Up @@ -96,12 +97,13 @@ In the following table the list of parameters that can be provided to the `pdf_v
| annotation_outline_size | Size of the outline around each annotation in pixels. Defaults to 1 pixel. |
| pages_to_render | Filter the rendering to a specific set of pages. By default, all pages are rendered. |
| render_text | Enable a layer of text on top of the PDF document. The text may be selected and copied. **NOTE** to avoid breaking existing deployments, we made this optional at first, also considering that having many annotations might interfere with the copy-paste. |
| zoom_level | The zoom level of the PDF viewer. Can be a float (0.1-10.0), `"auto"` for fit-to-width, `"auto-height"` for fit-to-height, or `None` (defaults to auto-fit to width). When zoom controls are enabled, users can interactively adjust the zoom level. |
| viewer_align | The alignment of the PDF viewer within its container. Can be `"center"` (default), `"left"`, or `"right"`. |
| zoom_level | The zoom level of the PDF viewer. Can be a float (0.1-10.0), `"auto"` for fit-to-width, `"auto-height"` for fit-to-height, or `None` (defaults to auto-fit to width). When zoom controls are enabled, users can interactively adjust the zoom level. |
| viewer_align | The alignment of the PDF viewer within its container. Can be `"center"` (default), `"left"`, or `"right"`. |
| show_page_separator | Whether to show a horizontal separator line between PDF pages. Defaults to `True`. |
| scroll_to_page | Scroll to a specific page when the component is rendered. The parameter is an integer, which represent the positional value of the page. E.g. 1, will be the first page. Default is None. Require ints and ignores the parameters below zero. |
| scroll_to_annotation | Scroll to a specific annotation when the component is rendered. The parameter is an integer, which represent the positional value of the annotation. E.g. 1, will be the first annotation. Default is None (don't scroll). Mutually exclusive with `scroll_to_page`. Raise an exception if used with `scroll_to_page` |
| on_annotation_click | Callback function that is called when an annotation is clicked. The function receives the annotation as a parameter. |
| show_fullscreen_toggle | Whether to show a button to toggle maximized mode. This provides expanded viewing within the component boundaries rather than browser fullscreen. Defaults to `True`. |

### Annotation format

Expand Down
5 changes: 4 additions & 1 deletion streamlit_pdf_viewer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def pdf_viewer(
scroll_to_page: Optional[int] = None,
scroll_to_annotation: Optional[int] = None,
on_annotation_click: Optional[Callable[[dict], None]] = None,
show_fullscreen_toggle: bool = True,
):
"""
pdf_viewer function to display a PDF file in a Streamlit app.
Expand All @@ -59,6 +60,7 @@ def pdf_viewer(
:param scroll_to_page: Scroll to a specific page in the PDF. The parameter is an integer, which represent the positional value of the page. E.g. 1, will be the first page. Defaults to None.
:param scroll_to_annotation: Scroll to a specific annotation in the PDF. The parameter is an integer, which represent the positional value of the annotation. E.g. 1, will be the first annotation. Defaults to None.
:param on_annotation_click: A callback function that will be called when an annotation is clicked. The function should accept a single argument, which is the annotation that was clicked. Defaults to None.
:param show_fullscreen_toggle: Whether to show button to toggle maximized mode. This provides expanded viewing within the component boundaries rather than browser fullscreen. Defaults to True.

The function reads the PDF file (from a file path, URL, or binary data), encodes it in base64,
and uses a Streamlit component to render it in the app. It supports optional annotations and adjustable margins.
Expand Down Expand Up @@ -132,7 +134,8 @@ def pdf_viewer(
viewer_align=viewer_align,
show_page_separator=show_page_separator,
scroll_to_page=scroll_to_page,
scroll_to_annotation=scroll_to_annotation
scroll_to_annotation=scroll_to_annotation,
show_fullscreen_toggle=show_fullscreen_toggle,
)

# Execute the custom callback function
Expand Down
182 changes: 153 additions & 29 deletions streamlit_pdf_viewer/frontend/src/PdfViewer.vue
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
<template>
<div :style="pdfContainerStyle" ref="pdfContainer" id="pdfContainer" class="container-wrapper">
<div :style="pdfContainerStyle" ref="pdfContainer" id="pdfContainer" :class="['container-wrapper', { maximized: isMaximized }]">
<div class="scrolling-container">
<div id="pdfViewer"></div>
</div>
<div class="zoom-controls">
<button class="zoom-button" @click.stop="toggleZoomPanel">
{{ Math.round(currentZoom * 100) }}%
</button>
<div class="control-buttons">
<div class="top-buttons">
<button v-if="showFullscreen" class="control-button" @click.stop="toggleMaximized" :title="isMaximized ? 'Exit Maximized' : 'Enter Maximized'">
<svg v-if="!isMaximized" style="width:16px; height:16px; vertical-align: middle;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>
<svg v-else style="width:16px; height:16px; vertical-align: middle;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/></svg>
</button>
<button class="control-button" @click.stop="toggleZoomPanel">
{{ Math.round(currentZoom * 100) }}%
</button>
</div>
<div v-if="showZoomPanel" class="zoom-panel">
<div class="zoom-input-container">
<input
type="number"
class="zoom-input"
v-model="manualZoomInput"
@keyup.enter="applyManualZoom"
@blur="applyManualZoom"
type="number"
class="zoom-input"
v-model="manualZoomInput"
@keyup.enter="applyManualZoom"
@blur="applyManualZoom"
/>
<span class="zoom-input-percent">%</span>
</div>
Expand Down Expand Up @@ -76,6 +82,9 @@ export default {
const localZoomLevel = ref(initialZoom);
const currentZoom = ref(1); // Will be updated on render

const isMaximized = ref(false);
const showFullscreen = props.args.show_fullscreen_toggle === true;

const zoomPresets = [0.5, 0.75, 1, 1.25, 1.5, 2];
const pdfInstance = ref(null);
const pdfContainer = ref(null);
Expand All @@ -100,23 +109,40 @@ export default {

const pdfContainerStyle = computed(() => {
const result = parseWidthValue(props.args.width, window.innerWidth);
const widthCSS = result.type === "percent" ? `${result.value * 100}%` : `${result.value}px`;
let widthCSS, heightCSS;

if (isMaximized.value) {
// In maximized mode, use full viewport
widthCSS = '100vw';
heightCSS = '100vh';
} else {
widthCSS = result.type === "percent" ? `${result.value * 100}%` : `${result.value}px`;
heightCSS = props.args.height ? `${props.args.height}px` : 'auto';
}

const style = {
width: widthCSS,
height: props.args.height ? `${props.args.height}px` : 'auto',
position: 'relative',
height: heightCSS,
position: isMaximized.value ? 'fixed' : 'relative',
top: isMaximized.value ? '0' : 'auto',
left: isMaximized.value ? '0' : 'auto',
zIndex: isMaximized.value ? '9999' : 'auto',
backgroundColor: isMaximized.value ? 'white' : 'transparent',
transition: 'all 0.3s ease-in-out',
};

const align = props.args.viewer_align || 'center';
if (align === 'center') {
style.marginLeft = 'auto';
style.marginRight = 'auto';
} else if (align === 'left') {
style.marginLeft = '0';
style.marginRight = 'auto';
} else if (align === 'right') {
style.marginLeft = 'auto';
style.marginRight = '0';
if (!isMaximized.value) {
const align = props.args.viewer_align || 'center';
if (align === 'center') {
style.marginLeft = 'auto';
style.marginRight = 'auto';
} else if (align === 'left') {
style.marginLeft = '0';
style.marginRight = 'auto';
} else if (align === 'right') {
style.marginLeft = 'auto';
style.marginRight = '0';
}
}

return style;
Expand Down Expand Up @@ -391,6 +417,33 @@ export default {
}
};

// Handle maximized state changes without full re-render
const handleMaximizedResize = () => {
if (isMaximized.value) {
// When maximized, adjust container dimensions without re-rendering PDF
const scrollingContainer = document.querySelector('.scrolling-container');
if (scrollingContainer) {
scrollingContainer.style.height = 'calc(100vh - 120px)'; // Account for control buttons
}

// Adjust PDF viewer width to fit the maximized container
const pdfViewer = document.getElementById("pdfViewer");
if (pdfViewer && maxWidth.value) {
pdfViewer.style.width = '100%';
pdfViewer.style.maxWidth = 'none';
}
} else {
// Restore original dimensions when exiting maximized mode
const scrollingContainer = document.querySelector('.scrolling-container');
if (scrollingContainer) {
scrollingContainer.style.height = '';
}

// Trigger a single re-render to restore original layout
handleResize();
}
};
Comment on lines +420 to +445
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Full re-render on exit may be expensive for large PDFs.

Line 443 calls handleResize() which re-renders the entire PDF when exiting maximized mode. For large documents, this could cause a noticeable delay or freeze.

Consider alternatives:

  1. Restore the original layout without re-rendering by reverting CSS changes only
  2. If re-render is necessary, show a loading indicator during the operation
  3. Cache the rendered pages to avoid full re-computation

Additionally, line 426 uses a magic number 120px for control button height. Consider extracting this to a constant or computing it dynamically:

+const CONTROL_BUTTONS_HEIGHT = 120; // Height of control buttons area
+
 const handleMaximizedResize = () => {
   if (isMaximized.value) {
     const scrollingContainer = document.querySelector('.scrolling-container');
     if (scrollingContainer) {
-      scrollingContainer.style.height = 'calc(100vh - 120px)';
+      scrollingContainer.style.height = `calc(100vh - ${CONTROL_BUTTONS_HEIGHT}px)`;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Handle maximized state changes without full re-render
const handleMaximizedResize = () => {
if (isMaximized.value) {
// When maximized, adjust container dimensions without re-rendering PDF
const scrollingContainer = document.querySelector('.scrolling-container');
if (scrollingContainer) {
scrollingContainer.style.height = 'calc(100vh - 120px)'; // Account for control buttons
}
// Adjust PDF viewer width to fit the maximized container
const pdfViewer = document.getElementById("pdfViewer");
if (pdfViewer && maxWidth.value) {
pdfViewer.style.width = '100%';
pdfViewer.style.maxWidth = 'none';
}
} else {
// Restore original dimensions when exiting maximized mode
const scrollingContainer = document.querySelector('.scrolling-container');
if (scrollingContainer) {
scrollingContainer.style.height = '';
}
// Trigger a single re-render to restore original layout
handleResize();
}
};
const CONTROL_BUTTONS_HEIGHT = 120; // Height of control buttons area
// Handle maximized state changes without full re-render
const handleMaximizedResize = () => {
if (isMaximized.value) {
// When maximized, adjust container dimensions without re-rendering PDF
const scrollingContainer = document.querySelector('.scrolling-container');
if (scrollingContainer) {
scrollingContainer.style.height = `calc(100vh - ${CONTROL_BUTTONS_HEIGHT}px)`; // Account for control buttons
}
// Adjust PDF viewer width to fit the maximized container
const pdfViewer = document.getElementById("pdfViewer");
if (pdfViewer && maxWidth.value) {
pdfViewer.style.width = '100%';
pdfViewer.style.maxWidth = 'none';
}
} else {
// Restore original dimensions when exiting maximized mode
const scrollingContainer = document.querySelector('.scrolling-container');
if (scrollingContainer) {
scrollingContainer.style.height = '';
}
// Trigger a single re-render to restore original layout
handleResize();
}
};


watch(() => props.args.binary, () => {
handleResize();
});
Expand All @@ -401,9 +454,48 @@ export default {
});

watch(() => props.args.viewer_align, () => {
handleResize();
if (!isMaximized.value) {
handleResize();
}
});

// Watch for maximized state changes to handle edge cases
watch(isMaximized, (newVal) => {
if (newVal) {
// When entering maximized mode, ensure zoom controls work properly
setTimeout(() => {
const scrollingContainer = document.querySelector('.scrolling-container');
if (scrollingContainer) {
scrollingContainer.scrollTop = parseFloat(scrollingContainer.dataset.savedScrollTop || 0);
scrollingContainer.scrollLeft = parseFloat(scrollingContainer.dataset.savedScrollLeft || 0);
}
}, 100); // Small delay to ensure DOM has updated
}
});
Comment on lines +463 to +474
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Avoid setTimeout for DOM synchronization.

Lines 466-472 use setTimeout with a 100ms delay to wait for the DOM to update. This is fragile and could cause race conditions.

Use Vue's nextTick instead for proper DOM update synchronization:

 watch(isMaximized, (newVal) => {
   if (newVal) {
-    setTimeout(() => {
+    nextTick(() => {
       const scrollingContainer = document.querySelector('.scrolling-container');
       if (scrollingContainer) {
         scrollingContainer.scrollTop = parseFloat(scrollingContainer.dataset.savedScrollTop || 0);
         scrollingContainer.scrollLeft = parseFloat(scrollingContainer.dataset.savedScrollLeft || 0);
       }
-    }, 100);
+    });
   }
 });

Don't forget to import nextTick:

-import {onMounted, computed, ref, onUnmounted, watch} from "vue";
+import {onMounted, computed, ref, onUnmounted, watch, nextTick} from "vue";

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In streamlit_pdf_viewer/frontend/src/PdfViewer.vue around lines 463 to 474, the
watch callback uses setTimeout(…,100) to wait for DOM updates; replace this with
Vue's nextTick: import nextTick from 'vue' (or add it to the existing import),
make the watch callback async, await nextTick() instead of setTimeout, then run
the scrollingContainer.dataset restore logic immediately after the await so the
DOM synchronization is deterministic and the arbitrary delay is removed.


const toggleMaximized = () => {
isMaximized.value = !isMaximized.value;

// Save/restore scroll position and handle maximized state
const scrollingContainer = document.querySelector('.scrolling-container');
if (scrollingContainer) {
if (isMaximized.value) {
// Save scroll position when entering maximized mode
scrollingContainer.dataset.savedScrollTop = scrollingContainer.scrollTop;
scrollingContainer.dataset.savedScrollLeft = scrollingContainer.scrollLeft;
} else {
// Restore scroll position when exiting maximized mode
if (scrollingContainer.dataset.savedScrollTop !== undefined) {
scrollingContainer.scrollTop = parseFloat(scrollingContainer.dataset.savedScrollTop);
scrollingContainer.scrollLeft = parseFloat(scrollingContainer.dataset.savedScrollLeft);
}
}
}

// Handle the resize without full re-render
handleMaximizedResize();
};

const setZoom = (zoomLevel) => {
localZoomLevel.value = zoomLevel;
showZoomPanel.value = false;
Expand Down Expand Up @@ -453,22 +545,30 @@ export default {
};

const handleClickOutside = (event) => {
if (showZoomPanel.value && !event.target.closest('.zoom-controls')) {
if (showZoomPanel.value && !event.target.closest('.control-buttons')) {
showZoomPanel.value = false;
}
};

const handleKeyDown = (event) => {
if (event.key === 'Escape' && isMaximized.value) {
toggleMaximized();
}
};

const debouncedHandleResize = debounce(handleResize, 200);

onMounted(() => {
debouncedHandleResize();
window.addEventListener("resize", debouncedHandleResize);
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
});

onUnmounted(() => {
window.removeEventListener("resize", debouncedHandleResize);
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
});

return {
Expand All @@ -486,6 +586,9 @@ export default {
fitToHeight,
toggleZoomPanel,
applyManualZoom,
showFullscreen,
isMaximized,
toggleMaximized,
};
},
};
Expand All @@ -494,24 +597,46 @@ export default {
<style scoped>
.container-wrapper {
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

.scrolling-container {
height: 100%;
overflow: auto;
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

.container-wrapper.maximized {
position: fixed;
top: 0;
left: 0;
width: 100vw !important;
height: 100vh !important;
z-index: 9999;
background: white;
border-radius: 0;
box-shadow: 0 0 40px rgba(0, 0, 0, 0.3);
overflow: hidden;
}

.zoom-controls {
.control-buttons {
position: absolute;
top: 20px;
right: 20px;
z-index: 100;
display: flex;
flex-direction: column;
align-items: flex-end;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

.zoom-button {
.top-buttons {
display: flex;
gap: 8px;
margin-bottom: 8px;
}

.control-button {
background: rgba(40, 40, 40, 0.9);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
Expand All @@ -522,10 +647,9 @@ export default {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
font-weight: 500;
transition: background 0.2s;
margin-bottom: 8px;
}

.zoom-button:hover {
.control-button:hover {
background: rgba(50, 50, 50, 0.9);
}

Expand Down
Loading