feat: implement PDF viewer, floating text toolbar, and document editing gateway router
This commit is contained in:
@@ -657,7 +657,7 @@ function App() {
|
||||
hasSignature={!!pendingSignature}
|
||||
signatureImageUrl={pendingSignature?.url}
|
||||
signatureAspect={pendingSignature?.aspect}
|
||||
activeStamp={activeStamp?.label ?? null}
|
||||
activeStamp={activeStamp}
|
||||
annotations={annotations}
|
||||
canCopy={can('canCopy')}
|
||||
onFieldChange={(id, value, i) => {
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import type { Rect } from '../lib/coordinateMapping';
|
||||
|
||||
interface FloatingTextToolbarProps {
|
||||
selection: { text: string; bbox: Rect; lines: Rect[] };
|
||||
onAction: (action: 'copy' | 'comment' | 'highlight' | 'underline' | 'strikeout' | 'squiggly' | 'redact' | 'edit', overrideColor?: string) => void;
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'#facc15', '#4ade80', '#2dd4bf', '#a78bfa', '#e879f9',
|
||||
'#fb923c', '#60a5fa', '#f472b6', '#22d3ee', '#34d399',
|
||||
'#16a34a', '#a855f7', '#2563eb', '#fef08a', '#ef4444',
|
||||
'#ffffff', '#e5e5e5', '#a3a3a3', '#52525b', '#000000',
|
||||
];
|
||||
|
||||
export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ selection, onAction }) => {
|
||||
const { bbox } = selection;
|
||||
// Calculate position: just above the bounding box
|
||||
const top = bbox.y - 48; // 48px above
|
||||
const left = bbox.x;
|
||||
|
||||
const [openDropdown, setOpenDropdown] = useState<'highlight' | 'underline' | 'strikeout' | null>(null);
|
||||
const [toolColors, setToolColors] = useState({
|
||||
highlight: '#facc15',
|
||||
underline: '#f43f5e',
|
||||
strikeout: '#ef4444',
|
||||
});
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setOpenDropdown(null);
|
||||
}
|
||||
};
|
||||
if (openDropdown) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [openDropdown]);
|
||||
|
||||
const handleAction = (action: 'highlight' | 'underline' | 'strikeout') => {
|
||||
onAction(action, toolColors[action]);
|
||||
setOpenDropdown(null);
|
||||
};
|
||||
|
||||
const ColorPicker = ({ action }: { action: 'highlight' | 'underline' | 'strikeout' }) => (
|
||||
<div className="absolute top-full left-0 mt-1 p-2 bg-[#262626] border border-[#3f3f46] rounded-md shadow-xl w-48 z-50 flex flex-col gap-2">
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
className={`w-8 h-8 rounded-sm ${toolColors[action] === c ? 'ring-2 ring-white ring-offset-1 ring-offset-[#262626]' : ''}`}
|
||||
style={{ backgroundColor: c }}
|
||||
onClick={() => {
|
||||
setToolColors(prev => ({ ...prev, [action]: c }));
|
||||
onAction(action, c);
|
||||
setOpenDropdown(null);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-[#a1a1aa] mt-1 cursor-pointer hover:text-white transition-colors">More colors</div>
|
||||
<div className="flex items-center justify-between text-xs text-[#a1a1aa] border-t border-[#3f3f46] pt-2 mt-1">
|
||||
<span>Opacity</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="hover:text-white">−</button>
|
||||
<span>100%</span>
|
||||
<button className="hover:text-white">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute z-50 flex items-center gap-1 bg-[#262626] rounded-[6px] shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1.5 border border-[#3f3f46]"
|
||||
style={{ top, left, pointerEvents: 'auto' }}
|
||||
onMouseDown={(e) => {
|
||||
// Prevent clearing the selection layer when clicking the toolbar
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => onAction('copy')}
|
||||
className="p-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
|
||||
title="Copy"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onAction('comment')}
|
||||
className="p-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
|
||||
title="Add Comment"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
|
||||
<line x1="9" y1="12" x2="15" y2="12" />
|
||||
<line x1="12" y1="9" x2="12" y2="15" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="w-px h-4 bg-[#52525b] mx-1" />
|
||||
|
||||
<div className="relative flex items-center group">
|
||||
<button
|
||||
onClick={() => handleAction('highlight')}
|
||||
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
|
||||
title="Highlight"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m9 11-6 6v3h9l3-3"/>
|
||||
<path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"/>
|
||||
<path d="M12 21h10" strokeWidth="3" stroke={toolColors.highlight} />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpenDropdown(openDropdown === 'highlight' ? null : 'highlight')}
|
||||
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m6 9 6 6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
{openDropdown === 'highlight' && <ColorPicker action="highlight" />}
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center group">
|
||||
<button
|
||||
onClick={() => handleAction('underline')}
|
||||
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
|
||||
title="Underline"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 4v6a6 6 0 0 0 12 0V4"/>
|
||||
<line x1="4" y1="20" x2="20" y2="20" stroke={toolColors.underline} strokeWidth="3" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpenDropdown(openDropdown === 'underline' ? null : 'underline')}
|
||||
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m6 9 6 6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
{openDropdown === 'underline' && <ColorPicker action="underline" />}
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center group">
|
||||
<button
|
||||
onClick={() => handleAction('strikeout')}
|
||||
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
|
||||
title="Strikeout"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 4H9a3 3 0 0 0-2.83 4"/>
|
||||
<path d="M14 12a4 4 0 0 1 0 8H6"/>
|
||||
<line x1="4" y1="12" x2="20" y2="12" stroke={toolColors.strikeout} strokeWidth="2.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpenDropdown(openDropdown === 'strikeout' ? null : 'strikeout')}
|
||||
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m6 9 6 6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
{openDropdown === 'strikeout' && <ColorPicker action="strikeout" />}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-[#52525b] mx-1" />
|
||||
|
||||
<button
|
||||
onClick={() => onAction('redact')}
|
||||
className="px-2 py-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors gap-1.5 text-xs font-medium"
|
||||
title="Redact Text"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 3h18v18H3z"/>
|
||||
<path d="M3 3l18 18"/>
|
||||
</svg>
|
||||
Redact Text
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onAction('edit')}
|
||||
className="px-2 py-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors gap-1.5 text-xs font-medium"
|
||||
title="Edit Text"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/>
|
||||
<path d="M15 5l4 4"/>
|
||||
</svg>
|
||||
Edit Text
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -11,13 +11,14 @@ import { SearchOverlayLayer } from './SearchOverlayLayer';
|
||||
import type { Rect } from '../lib/coordinateMapping';
|
||||
import { gatewayService } from '../lib/gatewayService';
|
||||
import type { SearchResult, PageInfo, Glyph } from '../lib/gatewayService';
|
||||
import type { ToolSettings } from '../lib/tools';
|
||||
import type { ToolSettings, ToolId, StampPreset } from '../lib/tools';
|
||||
import { toast } from '../lib/toast';
|
||||
import { RedactionLayer } from './RedactionLayer';
|
||||
import { StreamEditLayer } from './StreamEditLayer';
|
||||
import { wasmFreeDocument } from '../lib/pdfiumEngine';
|
||||
import { SignaturePlacementOverlay } from './SignaturePlacementOverlay';
|
||||
import type { PlacementRect } from './SignaturePlacementOverlay';
|
||||
import { FloatingTextToolbar } from './FloatingTextToolbar';
|
||||
|
||||
interface PDFViewerProps {
|
||||
documentId: string;
|
||||
@@ -33,7 +34,7 @@ interface PDFViewerProps {
|
||||
/** The data-URL of the pending signature image (for the placement preview) */
|
||||
signatureImageUrl?: string;
|
||||
signatureAspect?: number;
|
||||
activeStamp: string | null;
|
||||
activeStamp: StampPreset | null;
|
||||
annotations: Annotation[];
|
||||
searchQuery?: string;
|
||||
searchResults?: SearchResult[];
|
||||
@@ -121,6 +122,13 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
rect: PlacementRect;
|
||||
} | null>(null);
|
||||
|
||||
const [textSelection, setTextSelection] = useState<{
|
||||
pageIndex: number;
|
||||
text: string;
|
||||
bbox: Rect;
|
||||
lines: Rect[];
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setPageTexts({});
|
||||
const prev = prevDocumentIdRef.current;
|
||||
@@ -315,6 +323,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
}, [textToolActive, visiblePages, documentId, pageTexts]);
|
||||
|
||||
const handleTextSelection = (text: string, bbox: Rect, lines: Rect[], pageIndex: number) => {
|
||||
// This is still called on Ctrl+C for select mode
|
||||
if (activeTool === 'select') {
|
||||
if (!canCopy) {
|
||||
toast("Copying is not permitted by this document's restrictions", 'error');
|
||||
@@ -372,6 +381,81 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
}
|
||||
};
|
||||
|
||||
const handleToolbarAction = (action: string, overrideColor?: string) => {
|
||||
if (!textSelection) return;
|
||||
const { pageIndex, text, bbox, lines } = textSelection;
|
||||
switch (action) {
|
||||
case 'copy':
|
||||
if (!canCopy) {
|
||||
toast("Copying is not permitted by this document's restrictions", 'error');
|
||||
break;
|
||||
}
|
||||
navigator.clipboard?.writeText(text).then(
|
||||
() => toast(`Copied ${text.length} character${text.length > 1 ? 's' : ''}`, 'success'),
|
||||
() => toast('Copy failed — clipboard unavailable', 'error')
|
||||
);
|
||||
break;
|
||||
case 'highlight': {
|
||||
const newAnno: Annotation = {
|
||||
id: generateUniqueId(),
|
||||
type: 'highlight',
|
||||
pageIndex,
|
||||
bbox: {
|
||||
x: bbox.x / zoom,
|
||||
y: bbox.y / zoom,
|
||||
width: bbox.width / zoom,
|
||||
height: bbox.height / zoom,
|
||||
},
|
||||
color: overrideColor || toolSettings.highlightColor,
|
||||
opacity: toolSettings.highlightOpacity,
|
||||
author: 'Current User',
|
||||
content: text,
|
||||
};
|
||||
onAnnotationAdded?.(newAnno);
|
||||
break;
|
||||
}
|
||||
case 'underline':
|
||||
onDecorateText?.(pageIndex, lines, 'underline', overrideColor || toolSettings.underlineColor);
|
||||
break;
|
||||
case 'strikeout':
|
||||
onDecorateText?.(pageIndex, lines, 'strikeout', overrideColor || toolSettings.strikeoutColor);
|
||||
break;
|
||||
case 'squiggly':
|
||||
onDecorateText?.(pageIndex, lines, 'squiggly', overrideColor || toolSettings.squigglyColor);
|
||||
break;
|
||||
case 'redact':
|
||||
onMarkRedaction?.(pageIndex, {
|
||||
x: bbox.x / zoom,
|
||||
y: bbox.y / zoom,
|
||||
width: bbox.width / zoom,
|
||||
height: bbox.height / zoom
|
||||
});
|
||||
break;
|
||||
case 'edit':
|
||||
toast('Please select the Edit Text tool from the toolbar to edit text content', 'info');
|
||||
break;
|
||||
case 'comment': {
|
||||
const newAnno: Annotation = {
|
||||
id: generateUniqueId(),
|
||||
type: 'comment',
|
||||
pageIndex,
|
||||
bbox: {
|
||||
x: bbox.x / zoom,
|
||||
y: bbox.y / zoom,
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
color: '#facc15',
|
||||
author: 'Current User',
|
||||
content: 'New Comment\n\n' + text,
|
||||
};
|
||||
onAnnotationAdded?.(newAnno);
|
||||
break;
|
||||
}
|
||||
}
|
||||
setTextSelection(null);
|
||||
};
|
||||
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [panStart, setPanStart] = useState({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 });
|
||||
|
||||
@@ -496,21 +580,27 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
glyphs={pageTexts[page.index] || []}
|
||||
mode={(activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly' || activeTool === 'redact') ? 'highlight' : 'select'}
|
||||
onTextSelected={(text, bbox, lines) => handleTextSelection(text, bbox, lines, page.index)}
|
||||
onSelectionChange={(sel) => setTextSelection(sel ? { ...sel, pageIndex: page.index } : null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTool === 'redact' && (
|
||||
<RedactionLayer
|
||||
pageIndex={page.index}
|
||||
width={page.width}
|
||||
height={page.height}
|
||||
onRedactionSelected={(bounds) => onMarkRedaction?.(page.index, bounds)}
|
||||
pendingRedactions={pendingRedactions.filter(r => r.pageIndex === page.index)}
|
||||
onRemoveRedaction={onRemoveRedaction}
|
||||
mode={redactionMode}
|
||||
{textSelection && textSelection.pageIndex === page.index && (
|
||||
<FloatingTextToolbar
|
||||
selection={textSelection}
|
||||
onAction={(action, color) => handleToolbarAction(action as any, color)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RedactionLayer
|
||||
pageIndex={page.index}
|
||||
width={page.width}
|
||||
height={page.height}
|
||||
onRedactionSelected={(bounds) => onMarkRedaction?.(page.index, bounds)}
|
||||
pendingRedactions={pendingRedactions.filter(r => r.pageIndex === page.index)}
|
||||
onRemoveRedaction={onRemoveRedaction}
|
||||
mode={(activeTool === 'redact' && redactionMode === 'area') ? 'area' : 'text'}
|
||||
/>
|
||||
|
||||
<OverlayLayer
|
||||
pageIndex={page.index}
|
||||
width={page.width}
|
||||
|
||||
@@ -13,6 +13,7 @@ interface SelectionLayerProps {
|
||||
glyphs: Glyph[];
|
||||
mode?: 'select' | 'highlight';
|
||||
onTextSelected?: (text: string, bbox: Rect, lines: Rect[]) => void;
|
||||
onSelectionChange?: (sel: { text: string, bbox: Rect, lines: Rect[] } | null) => void;
|
||||
}
|
||||
|
||||
const SEL_START_EVT = 'pdf-selection-start';
|
||||
@@ -25,6 +26,7 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
|
||||
glyphs,
|
||||
mode = 'select',
|
||||
onTextSelected,
|
||||
onSelectionChange,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const model = useMemo(() => new TextSelectionModel(glyphs), [glyphs]);
|
||||
@@ -62,7 +64,8 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
|
||||
setBox(null);
|
||||
drag.current = null;
|
||||
boxStart.current = null;
|
||||
}, []);
|
||||
onSelectionChange?.(null);
|
||||
}, [onSelectionChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const onOther = (e: Event) => {
|
||||
@@ -160,14 +163,20 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
|
||||
const cur = selRef.current;
|
||||
if (!cur || cur.start === cur.end) {
|
||||
setSel(null);
|
||||
onSelectionChange?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = model.textOfRange(cur);
|
||||
const u = model.unionRect(cur);
|
||||
const lines = model.rectsOfRange(cur).map((q) => ({ x: q.x * zoom, y: q.y * zoom, width: q.w * zoom, height: q.h * zoom }));
|
||||
|
||||
if (mode === 'highlight') {
|
||||
const text = model.textOfRange(cur);
|
||||
const u = model.unionRect(cur);
|
||||
const lines = model.rectsOfRange(cur).map((q) => ({ x: q.x * zoom, y: q.y * zoom, width: q.w * zoom, height: q.h * zoom }));
|
||||
if (u) onTextSelected?.(text, { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom }, lines);
|
||||
setSel(null);
|
||||
onSelectionChange?.(null);
|
||||
} else if (mode === 'select') {
|
||||
if (u) onSelectionChange?.({ text, bbox: { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom }, lines });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -397,6 +397,7 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
|
||||
if "," in img_data_str:
|
||||
img_data_str = img_data_str.split(",", 1)[1]
|
||||
|
||||
img_data_str += "=" * ((4 - len(img_data_str) % 4) % 4)
|
||||
raw_bytes = base64.b64decode(img_data_str)
|
||||
img = Image.open(io.BytesIO(raw_bytes))
|
||||
img_rgba = img.convert("RGBA")
|
||||
@@ -407,6 +408,7 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
|
||||
bgra_bytes = img_bgra.tobytes()
|
||||
|
||||
fd, temp_path = tempfile.mkstemp(suffix=".bin", prefix="pdf_pixel_")
|
||||
temp_path = temp_path.replace("\\", "/")
|
||||
created_temp_files.append(temp_path)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as tmp:
|
||||
|
||||
Reference in New Issue
Block a user