Merge branch 'dev' of https://gitea.maskantech.in/gitea_admin/pdf into furqan
This commit is contained in:
@@ -17,6 +17,7 @@ add_library(pdfengine STATIC
|
||||
src/parser/pdfium_loader.cpp
|
||||
src/parser/pdfium_document.cpp
|
||||
src/parser/content_stream_parser.cpp
|
||||
src/parser/decoration_builder.cpp
|
||||
src/text/selection.cpp
|
||||
src/fonts/face/font_face.cpp
|
||||
src/fonts/face/free_type_manager.cpp
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#include "decoration_builder.hpp"
|
||||
|
||||
namespace pdfengine {
|
||||
|
||||
Path DecorationBuilder::buildUnderline(float x, float y, float width, float thickness, float offset) {
|
||||
Path p;
|
||||
// An underline is essentially a thin filled rectangle at (y + offset).
|
||||
// The caller is responsible for supplying the correctly signed offset.
|
||||
p.addRect(x, y + offset, width, thickness);
|
||||
return p;
|
||||
}
|
||||
|
||||
Path DecorationBuilder::buildStrikeout(float x, float y, float width, float thickness, float offset) {
|
||||
Path p;
|
||||
// A strikeout is similarly a thin filled rectangle, positioned higher up.
|
||||
p.addRect(x, y + offset, width, thickness);
|
||||
return p;
|
||||
}
|
||||
|
||||
Path DecorationBuilder::buildSquiggly(float x, float y, float width, float amplitude, float frequency) {
|
||||
Path p;
|
||||
if (width <= 0.0f) {
|
||||
return p;
|
||||
}
|
||||
|
||||
p.moveTo(x, y);
|
||||
|
||||
float currentX = x;
|
||||
float endX = x + width;
|
||||
|
||||
// Create a jagged squiggly line using line segments.
|
||||
// This is drawn as a stroked path rather than a filled rect.
|
||||
bool up = true;
|
||||
while (currentX < endX) {
|
||||
float nextX = currentX + (frequency / 2.0f);
|
||||
if (nextX > endX) {
|
||||
nextX = endX;
|
||||
// Adjust the final Y to keep the slope somewhat consistent if chopped early
|
||||
float ratio = (nextX - currentX) / (frequency / 2.0f);
|
||||
float nextY = y + (up ? amplitude : -amplitude) * ratio;
|
||||
p.lineTo(nextX, nextY);
|
||||
break;
|
||||
}
|
||||
|
||||
float nextY = y + (up ? amplitude : -amplitude);
|
||||
p.lineTo(nextX, nextY);
|
||||
|
||||
currentX = nextX;
|
||||
up = !up;
|
||||
}
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
} // namespace pdfengine
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <pdfengine/path.hpp>
|
||||
|
||||
namespace pdfengine {
|
||||
|
||||
// A utility class to generate vector paths for text markup and decorations
|
||||
class DecorationBuilder {
|
||||
public:
|
||||
// Builds a path representing a straight underline.
|
||||
// x, y: starting coordinates (usually the baseline origin)
|
||||
// width: length of the underline
|
||||
// thickness: thickness of the line (used to build a thin rectangle)
|
||||
// offset: vertical offset from y
|
||||
static Path buildUnderline(float x, float y, float width, float thickness = 1.0f, float offset = -2.0f);
|
||||
|
||||
// Builds a path representing a strikeout line.
|
||||
// x, y: starting coordinates (baseline)
|
||||
// width: length of the strikeout
|
||||
// thickness: thickness of the line
|
||||
// offset: vertical offset from y (typically goes up through the text)
|
||||
static Path buildStrikeout(float x, float y, float width, float thickness = 1.0f, float offset = 4.0f);
|
||||
|
||||
// Builds a path representing a squiggly underline (often used for spelling or grammar highlights).
|
||||
// x, y: starting coordinates
|
||||
// width: length of the squiggly
|
||||
// amplitude: height of the squiggly waves
|
||||
// frequency: horizontal width of a single wave cycle
|
||||
static Path buildSquiggly(float x, float y, float width, float amplitude = 2.0f, float frequency = 4.0f);
|
||||
};
|
||||
|
||||
} // namespace pdfengine
|
||||
@@ -17,6 +17,7 @@
|
||||
#include "fonts/pdf_fonts/font_fallback.hpp"
|
||||
#include "fonts/pdf_fonts/font_subset.hpp"
|
||||
#include "fonts/shaping/hb_shaper.hpp"
|
||||
#include "decoration_builder.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <spdlog/spdlog.h>
|
||||
@@ -2261,6 +2262,80 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
FPDF_ClosePage(page);
|
||||
} else if (type == "underline" || type == "strikeout" || type == "squiggly") {
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("{} operation missing 'data' object", type);
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
auto data = op["data"];
|
||||
double x = data.value("x", 0.0);
|
||||
double y = data.value("y", 0.0);
|
||||
double width = data.value("width", 0.0);
|
||||
double thickness = data.value("thickness", 1.0);
|
||||
std::string color = data.value("color", "#000000");
|
||||
|
||||
spdlog::info("Parsed {} operation: x={}, y={}, width={}", type, x, y, width);
|
||||
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) {
|
||||
spdlog::error("Failed to load page index {} for decoration", pageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
pdfengine::Path path;
|
||||
if (type == "underline") {
|
||||
path = DecorationBuilder::buildUnderline(x, y, width, thickness);
|
||||
} else if (type == "strikeout") {
|
||||
path = DecorationBuilder::buildStrikeout(x, y, width, thickness);
|
||||
} else if (type == "squiggly") {
|
||||
path = DecorationBuilder::buildSquiggly(x, y, width);
|
||||
}
|
||||
|
||||
if (!path.empty()) {
|
||||
const auto& segments = path.segments();
|
||||
float startX = segments[0].points[0].x;
|
||||
float startY = segments[0].points[0].y;
|
||||
FPDF_PAGEOBJECT pathObj = FPDFPageObj_CreateNewPath(startX, startY);
|
||||
|
||||
bool isFill = (type != "squiggly");
|
||||
bool isStroke = (type == "squiggly");
|
||||
|
||||
for (size_t i = 1; i < segments.size(); ++i) { // Start from 1 to skip first MoveTo
|
||||
const auto& seg = segments[i];
|
||||
if (seg.verb == Path::Verb::MoveTo) {
|
||||
FPDFPath_MoveTo(pathObj, seg.points[0].x, seg.points[0].y);
|
||||
} else if (seg.verb == Path::Verb::LineTo) {
|
||||
FPDFPath_LineTo(pathObj, seg.points[0].x, seg.points[0].y);
|
||||
} else if (seg.verb == Path::Verb::CubicBezierTo) {
|
||||
FPDFPath_BezierTo(pathObj, seg.points[0].x, seg.points[0].y,
|
||||
seg.points[1].x, seg.points[1].y,
|
||||
seg.points[2].x, seg.points[2].y);
|
||||
} else if (seg.verb == Path::Verb::Close) {
|
||||
FPDFPath_Close(pathObj);
|
||||
}
|
||||
}
|
||||
|
||||
FPDFPath_SetDrawMode(pathObj, isFill ? FPDF_FILLMODE_ALTERNATE : FPDF_FILLMODE_NONE, isStroke);
|
||||
|
||||
unsigned int r = 0, g = 0, b = 0;
|
||||
parseHexColor(color, r, g, b);
|
||||
|
||||
if (isFill) {
|
||||
FPDFPageObj_SetFillColor(pathObj, r, g, b, 255);
|
||||
}
|
||||
if (isStroke) {
|
||||
FPDFPageObj_SetStrokeColor(pathObj, r, g, b, 255);
|
||||
FPDFPageObj_SetStrokeWidth(pathObj, thickness);
|
||||
}
|
||||
|
||||
FPDFPage_InsertObject(page, pathObj);
|
||||
|
||||
if (!FPDFPage_GenerateContent(page)) {
|
||||
spdlog::error("Failed to generate page content after adding decoration");
|
||||
}
|
||||
}
|
||||
|
||||
FPDF_ClosePage(page);
|
||||
} else if (type == "redaction") {
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
|
||||
@@ -263,6 +263,29 @@ function App() {
|
||||
};
|
||||
|
||||
/* ---------------------------------------------- new overlay placements */
|
||||
const handleDecorateText = (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => {
|
||||
const ops: EditOperation[] = lines.map(line => {
|
||||
const pdf = viewportRectToPdf(line, zoom, pageHeightPts(pageIndex));
|
||||
return {
|
||||
id: rid('decor'), type, pageIndex,
|
||||
data: { x: pdf.x, y: pdf.y, width: pdf.width, thickness: 1.5, color }
|
||||
};
|
||||
});
|
||||
|
||||
// Optimistically show local overlays
|
||||
const newAnnos = lines.map(line => ({
|
||||
id: rid('locdec'),
|
||||
type,
|
||||
pageIndex,
|
||||
bbox: { x: line.x / zoom, y: line.y / zoom, width: line.width / zoom, height: line.height / zoom },
|
||||
color,
|
||||
author: 'Current User',
|
||||
} as Annotation));
|
||||
|
||||
setAnnotations(prev => [...prev, ...newAnnos]);
|
||||
applyOps(ops);
|
||||
};
|
||||
|
||||
const handlePlaceText = (pageIndex: number, rectPts: Rect, text: string) => {
|
||||
const pdf = viewportRectToPdf(rectPts, 1, pageHeightPts(pageIndex));
|
||||
applyOps([{
|
||||
@@ -549,6 +572,7 @@ function App() {
|
||||
onEditText={handleEditText}
|
||||
onPlaceStamp={handlePlaceStamp}
|
||||
onPlaceSignature={handlePlaceSignature}
|
||||
onDecorateText={handleDecorateText}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-[#98a1ad]">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Popover } from './ui';
|
||||
import {
|
||||
SelectIcon, PanIcon, HighlightIcon, DrawIcon, CommentIcon, TextBoxIcon,
|
||||
SignatureIcon, StampIcon, RedactIcon, InfoIcon, HelpIcon,
|
||||
UnderlineIcon, StrikeoutIcon, SquigglyIcon
|
||||
} from './icons';
|
||||
|
||||
interface ToolDef { id: ToolId; label: string; shortcut: string; icon: React.ReactNode; danger?: boolean }
|
||||
@@ -14,6 +15,9 @@ const TOOLS: (ToolDef | 'divider')[] = [
|
||||
{ id: 'pan', label: 'Pan', shortcut: 'H', icon: <PanIcon /> },
|
||||
'divider',
|
||||
{ id: 'highlight', label: 'Highlight', shortcut: 'K', icon: <HighlightIcon /> },
|
||||
{ id: 'underline', label: 'Underline', shortcut: 'U', icon: <UnderlineIcon /> },
|
||||
{ id: 'strikeout', label: 'Strikeout', shortcut: 'X', icon: <StrikeoutIcon /> },
|
||||
{ id: 'squiggly', label: 'Squiggly', shortcut: 'W', icon: <SquigglyIcon /> },
|
||||
{ id: 'draw', label: 'Draw (ink)', shortcut: 'D', icon: <DrawIcon /> },
|
||||
{ id: 'comment', label: 'Comment', shortcut: 'C', icon: <CommentIcon /> },
|
||||
{ id: 'textbox', label: 'Text box', shortcut: 'T', icon: <TextBoxIcon /> },
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ColorSwatches, Slider } from './ui';
|
||||
import {
|
||||
SelectIcon, PanIcon, HighlightIcon, DrawIcon, CommentIcon, TextBoxIcon,
|
||||
SignatureIcon, StampIcon, RedactIcon,
|
||||
UnderlineIcon, StrikeoutIcon, SquigglyIcon
|
||||
} from './icons';
|
||||
|
||||
interface ToolbarProps {
|
||||
@@ -29,6 +30,9 @@ const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
|
||||
signature: { label: 'Signature', icon: <SignatureIcon size={17} /> },
|
||||
stamp: { label: 'Stamp', icon: <StampIcon size={17} /> },
|
||||
redact: { label: 'Redact', icon: <RedactIcon size={17} /> },
|
||||
underline: { label: 'Underline', icon: <UnderlineIcon size={17} /> },
|
||||
strikeout: { label: 'Strikeout', icon: <StrikeoutIcon size={17} /> },
|
||||
squiggly: { label: 'Squiggly', icon: <SquigglyIcon size={17} /> },
|
||||
};
|
||||
|
||||
const Hint: React.FC<{ children: React.ReactNode; tone?: 'normal' | 'warn' }> = ({ children, tone = 'normal' }) => (
|
||||
@@ -78,6 +82,27 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTool === 'underline' && (
|
||||
<>
|
||||
<Label>Color</Label>
|
||||
<ColorSwatches value={settings.underlineColor} onChange={(c) => onSettingsChange({ underlineColor: c })} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTool === 'strikeout' && (
|
||||
<>
|
||||
<Label>Color</Label>
|
||||
<ColorSwatches value={settings.strikeoutColor} onChange={(c) => onSettingsChange({ strikeoutColor: c })} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTool === 'squiggly' && (
|
||||
<>
|
||||
<Label>Color</Label>
|
||||
<ColorSwatches value={settings.squigglyColor} onChange={(c) => onSettingsChange({ squigglyColor: c })} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTool === 'draw' && (
|
||||
<>
|
||||
<Label>Color</Label>
|
||||
|
||||
@@ -41,6 +41,9 @@ export const DrawIcon: IC = mk(<path d="M15.2 5.2l3.6 3.6m-2-5.1a2.5 2.5 0 013.6
|
||||
export const CommentIcon: IC = mk(<path d="M21 12a8 8 0 01-11.3 7.3L4 21l1.7-5.7A8 8 0 1121 12z" />);
|
||||
export const SignatureIcon: IC = mk(<><path d="M3 17c3 0 3-9 6-9s3 6 5 6 2-3 4-3" /><path d="M3 21h18" /></>);
|
||||
export const TextBoxIcon: IC = mk(<><path d="M4 7V5h16v2" /><path d="M9 19h6M12 5v14" /></>);
|
||||
export const UnderlineIcon: IC = mk(<><path d="M6 3v7a6 6 0 006 6 6 6 0 006-6V3" /><path d="M4 21h16" /></>);
|
||||
export const StrikeoutIcon: IC = mk(<><path d="M16 3H8a2 2 0 00-2 2v2M8 21h8a2 2 0 002-2v-2" /><path d="M12 3v18M4 12h16" /></>);
|
||||
export const SquigglyIcon: IC = mk(<path d="M3 12c1.5-3 3-3 4.5 0s3 3 4.5 0 3-3 4.5 0 3 3 4.5 0" />);
|
||||
export const StampIcon: IC = mk(<><path d="M9 12a3 3 0 113 0c-.7.6-1 1.3-1 2v1h-1v-1c0-.7-.3-1.4-1-2z" /><path d="M5 17h14M4 20h16" /></>);
|
||||
export const RedactIcon: IC = mk(<rect x="4" y="4" width="16" height="16" rx="1.5" fill="currentColor" stroke="none" />);
|
||||
export const ImageIcon: IC = mk(<><rect x="3" y="4" width="18" height="16" rx="2" /><circle cx="8.5" cy="9.5" r="1.5" /><path d="M21 17l-5-5L5 21" /></>);
|
||||
|
||||
@@ -8,7 +8,10 @@ export type ToolId =
|
||||
| 'edit_text'
|
||||
| 'signature'
|
||||
| 'stamp'
|
||||
| 'redact';
|
||||
| 'redact'
|
||||
| 'underline'
|
||||
| 'strikeout'
|
||||
| 'squiggly';
|
||||
|
||||
export interface ToolSettings {
|
||||
highlightColor: string;
|
||||
@@ -17,6 +20,9 @@ export interface ToolSettings {
|
||||
inkThickness: number;
|
||||
textColor: string;
|
||||
fontSize: number;
|
||||
underlineColor: string;
|
||||
strikeoutColor: string;
|
||||
squigglyColor: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_TOOL_SETTINGS: ToolSettings = {
|
||||
@@ -26,6 +32,9 @@ export const DEFAULT_TOOL_SETTINGS: ToolSettings = {
|
||||
inkThickness: 2,
|
||||
textColor: '#1f2937',
|
||||
fontSize: 14,
|
||||
underlineColor: '#2563eb',
|
||||
strikeoutColor: '#dc2626',
|
||||
squigglyColor: '#16a34a',
|
||||
};
|
||||
|
||||
export const TOOL_SHORTCUTS: Record<string, ToolId> = {
|
||||
@@ -39,6 +48,9 @@ export const TOOL_SHORTCUTS: Record<string, ToolId> = {
|
||||
s: 'signature',
|
||||
m: 'stamp',
|
||||
r: 'redact',
|
||||
u: 'underline',
|
||||
x: 'strikeout',
|
||||
w: 'squiggly',
|
||||
};
|
||||
|
||||
export const STAMP_PRESETS = [
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Rect } from '../lib/coordinateMapping';
|
||||
|
||||
export interface Annotation {
|
||||
id: string;
|
||||
type: 'highlight' | 'signature' | 'strikeout' | 'comment' | 'ink' | 'widget';
|
||||
type: 'highlight' | 'signature' | 'strikeout' | 'underline' | 'squiggly' | 'comment' | 'ink' | 'widget';
|
||||
bbox: Rect;
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
@@ -88,7 +88,7 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
>
|
||||
{annotations
|
||||
.filter((anno) => anno.pageIndex === undefined || anno.pageIndex === pageIndex)
|
||||
.filter((anno) => ['highlight', 'comment', 'strikeout', 'signature', 'widget'].includes(anno.type))
|
||||
.filter((anno) => ['highlight', 'comment', 'strikeout', 'underline', 'squiggly', 'signature', 'widget'].includes(anno.type))
|
||||
.map((anno) => {
|
||||
const scaledBbox = {
|
||||
x: anno.bbox.x * zoom,
|
||||
@@ -131,7 +131,18 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{anno.type === 'strikeout' && <div className="w-full h-[2px] bg-[#dc2626] opacity-[0.85]" />}
|
||||
{anno.type === 'strikeout' && <div className="w-full h-[1.5px] opacity-[0.85] absolute top-1/2 -translate-y-1/2" style={{ backgroundColor: anno.color || '#dc2626' }} />}
|
||||
{anno.type === 'underline' && <div className="w-full h-[1.5px] opacity-[0.85] absolute bottom-0" style={{ backgroundColor: anno.color || '#2563eb' }} />}
|
||||
{anno.type === 'squiggly' && (
|
||||
<svg width="100%" height="4" xmlns="http://www.w3.org/2000/svg" style={{position: 'absolute', bottom: 0, left: 0, opacity: 0.85}}>
|
||||
<defs>
|
||||
<pattern id={`sq-${anno.id}`} x="0" y="0" width="6" height="4" patternUnits="userSpaceOnUse">
|
||||
<path d="M 0 2 Q 1.5 0 3 2 T 6 2" fill="none" stroke={anno.color || '#16a34a'} strokeWidth="1.2" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="100%" height="4" fill={`url(#sq-${anno.id})`} />
|
||||
</svg>
|
||||
)}
|
||||
{anno.type === 'signature' && (
|
||||
<div className="text-[rgba(37,99,235,0.85)] bg-[rgba(255,255,255,0.85)] rounded-full p-1 shadow-[0_1px_2px_rgba(16,24,40,0.06),0_1px_3px_rgba(16,24,40,0.10)]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
|
||||
@@ -38,6 +38,7 @@ interface PDFViewerProps {
|
||||
onEditText?: (pageIndex: number, run: EditableRun, newText: string) => void;
|
||||
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
|
||||
onPlaceSignature?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
|
||||
onDecorateText?: (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => void;
|
||||
onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
|
||||
}
|
||||
|
||||
@@ -78,6 +79,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
onEditText,
|
||||
onPlaceStamp,
|
||||
onPlaceSignature,
|
||||
onDecorateText,
|
||||
onFieldChange,
|
||||
}, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -268,7 +270,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
}, [visiblePages, documentId, verifiedPages]);
|
||||
|
||||
// Fetch real glyph bounds for visible pages while text tools are active.
|
||||
const textToolActive = activeTool === 'select' || activeTool === 'highlight';
|
||||
const textToolActive = activeTool === 'select' || activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly';
|
||||
useEffect(() => {
|
||||
if (!textToolActive || !documentId) return;
|
||||
let active = true;
|
||||
@@ -289,7 +291,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
return () => { active = false; };
|
||||
}, [textToolActive, visiblePages, documentId, pageTexts]);
|
||||
|
||||
const handleTextSelection = (text: string, bbox: Rect, pageIndex: number) => {
|
||||
const handleTextSelection = (text: string, bbox: Rect, lines: Rect[], pageIndex: number) => {
|
||||
if (activeTool === 'select') {
|
||||
if (text.trim()) {
|
||||
navigator.clipboard?.writeText(text).then(
|
||||
@@ -318,6 +320,14 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
content: text,
|
||||
};
|
||||
onAnnotationAdded?.(newAnno);
|
||||
return;
|
||||
}
|
||||
if (activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly') {
|
||||
const color =
|
||||
activeTool === 'underline' ? toolSettings.underlineColor :
|
||||
activeTool === 'strikeout' ? toolSettings.strikeoutColor :
|
||||
toolSettings.squigglyColor;
|
||||
onDecorateText?.(pageIndex, lines, activeTool, color);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -403,15 +413,15 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
/>
|
||||
|
||||
{/* Text Selection Dragging Layer */}
|
||||
{(activeTool === 'select' || activeTool === 'highlight') && (
|
||||
{(activeTool === 'select' || activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly') && (
|
||||
<SelectionLayer
|
||||
pageIndex={page.index}
|
||||
width={page.width}
|
||||
height={page.height}
|
||||
zoom={zoom}
|
||||
glyphs={pageTexts[page.index] || []}
|
||||
mode={activeTool === 'highlight' ? 'highlight' : 'select'}
|
||||
onTextSelected={(text, bbox) => handleTextSelection(text, bbox, page.index)}
|
||||
mode={(activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly') ? 'highlight' : 'select'}
|
||||
onTextSelected={(text, bbox, lines) => handleTextSelection(text, bbox, lines, page.index)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ interface SelectionLayerProps {
|
||||
glyphs: Glyph[];
|
||||
/** 'select' keeps the selection live for copy; 'highlight' emits on mouse-up. */
|
||||
mode?: 'select' | 'highlight';
|
||||
onTextSelected?: (text: string, bbox: Rect) => void;
|
||||
onTextSelected?: (text: string, bbox: Rect, lines: Rect[]) => void;
|
||||
}
|
||||
|
||||
// Broadcast so that starting a selection on one page clears every other page's.
|
||||
@@ -92,7 +92,8 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
|
||||
} else if (mod && e.key.toLowerCase() === 'c' && has && mode === 'select') {
|
||||
const text = model.textOfRange(cur!);
|
||||
const u = model.unionRect(cur!);
|
||||
if (text && u) onTextSelected?.(text, { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom });
|
||||
const lines = model.rectsOfRange(cur!).map((q) => ({ x: q.x * zoom, y: q.y * zoom, width: q.w * zoom, height: q.h * zoom }));
|
||||
if (text && u) onTextSelected?.(text, { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom }, lines);
|
||||
} else if (e.key === 'Escape' && has) {
|
||||
clear();
|
||||
}
|
||||
@@ -160,7 +161,7 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
|
||||
const handleMouseUp = () => {
|
||||
// Mock-mode: report the raw drag box so highlighting still works.
|
||||
if (!hasGlyphs) {
|
||||
if (box && box.width > 3 && box.height > 3) onTextSelected?.('', box);
|
||||
if (box && box.width > 3 && box.height > 3) onTextSelected?.('', box, [box]);
|
||||
setBox(null);
|
||||
boxStart.current = null;
|
||||
return;
|
||||
@@ -174,7 +175,8 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
|
||||
if (mode === 'highlight') {
|
||||
const text = model.textOfRange(cur);
|
||||
const u = model.unionRect(cur);
|
||||
if (u) onTextSelected?.(text, { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom });
|
||||
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);
|
||||
}
|
||||
// 'select' mode: keep the selection live for Ctrl+C.
|
||||
|
||||
@@ -223,6 +223,31 @@ class ReplaceTextOperation(BaseModel):
|
||||
data: ReplaceTextData
|
||||
|
||||
|
||||
class DecorationData(BaseModel):
|
||||
x: float
|
||||
y: float
|
||||
width: float
|
||||
thickness: float | None = None
|
||||
color: str | None = None
|
||||
|
||||
class UnderlineOperation(BaseModel):
|
||||
id: str
|
||||
type: Literal["underline"]
|
||||
pageIndex: int = Field(..., ge=0)
|
||||
data: DecorationData
|
||||
|
||||
class StrikeoutOperation(BaseModel):
|
||||
id: str
|
||||
type: Literal["strikeout"]
|
||||
pageIndex: int = Field(..., ge=0)
|
||||
data: DecorationData
|
||||
|
||||
class SquigglyOperation(BaseModel):
|
||||
id: str
|
||||
type: Literal["squiggly"]
|
||||
pageIndex: int = Field(..., ge=0)
|
||||
data: DecorationData
|
||||
|
||||
EditOperation = Annotated[
|
||||
TextOverlayOperation
|
||||
| RedactionOperation
|
||||
@@ -237,7 +262,10 @@ EditOperation = Annotated[
|
||||
| UpdateFieldOperation
|
||||
| DeleteAnnotationOperation
|
||||
| UpdateAnnotationOperation
|
||||
| ReplaceTextOperation,
|
||||
| ReplaceTextOperation
|
||||
| UnderlineOperation
|
||||
| StrikeoutOperation
|
||||
| SquigglyOperation,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user