Underline + decoration renderer (DecorationBuilder)

This commit is contained in:
saqib mir
2026-06-11 19:30:27 +05:30
parent b52890818b
commit 4d900c3d0c
13 changed files with 310 additions and 16 deletions
+1
View File
@@ -17,6 +17,7 @@ add_library(pdfengine STATIC
src/parser/pdfium_loader.cpp src/parser/pdfium_loader.cpp
src/parser/pdfium_document.cpp src/parser/pdfium_document.cpp
src/parser/content_stream_parser.cpp src/parser/content_stream_parser.cpp
src/parser/decoration_builder.cpp
src/fonts/face/font_face.cpp src/fonts/face/font_face.cpp
src/fonts/face/free_type_manager.cpp src/fonts/face/free_type_manager.cpp
src/fonts/loader/font_resolver.cpp src/fonts/loader/font_resolver.cpp
+55
View File
@@ -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
+32
View File
@@ -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
+75
View File
@@ -14,6 +14,7 @@
#include "fonts/pdf_fonts/font.hpp" #include "fonts/pdf_fonts/font.hpp"
#include "fonts/pdf_fonts/font_fallback.hpp" #include "fonts/pdf_fonts/font_fallback.hpp"
#include "fonts/shaping/hb_shaper.hpp" #include "fonts/shaping/hb_shaper.hpp"
#include "decoration_builder.hpp"
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <spdlog/spdlog.h> #include <spdlog/spdlog.h>
@@ -2190,6 +2191,80 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
return std::unexpected(EngineError::Unknown); 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); FPDF_ClosePage(page);
} else if (type == "redaction") { } else if (type == "redaction") {
if (!op.contains("data") || !op["data"].is_object()) { if (!op.contains("data") || !op["data"].is_object()) {
+24
View File
@@ -262,6 +262,29 @@ function App() {
}; };
/* ---------------------------------------------- new overlay placements */ /* ---------------------------------------------- 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 handlePlaceText = (pageIndex: number, rectPts: Rect, text: string) => {
const pdf = viewportRectToPdf(rectPts, 1, pageHeightPts(pageIndex)); const pdf = viewportRectToPdf(rectPts, 1, pageHeightPts(pageIndex));
applyOps([{ applyOps([{
@@ -532,6 +555,7 @@ function App() {
onPlaceText={handlePlaceText} onPlaceText={handlePlaceText}
onPlaceStamp={handlePlaceStamp} onPlaceStamp={handlePlaceStamp}
onPlaceSignature={handlePlaceSignature} onPlaceSignature={handlePlaceSignature}
onDecorateText={handleDecorateText}
/> />
) : ( ) : (
<div className="flex h-full flex-col items-center justify-center gap-3 text-[#98a1ad]"> <div className="flex h-full flex-col items-center justify-center gap-3 text-[#98a1ad]">
+4
View File
@@ -5,6 +5,7 @@ import { Popover } from './ui';
import { import {
SelectIcon, PanIcon, HighlightIcon, DrawIcon, CommentIcon, TextBoxIcon, SelectIcon, PanIcon, HighlightIcon, DrawIcon, CommentIcon, TextBoxIcon,
SignatureIcon, StampIcon, RedactIcon, InfoIcon, HelpIcon, SignatureIcon, StampIcon, RedactIcon, InfoIcon, HelpIcon,
UnderlineIcon, StrikeoutIcon, SquigglyIcon
} from './icons'; } from './icons';
interface ToolDef { id: ToolId; label: string; shortcut: string; icon: React.ReactNode; danger?: boolean } 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 /> }, { id: 'pan', label: 'Pan', shortcut: 'H', icon: <PanIcon /> },
'divider', 'divider',
{ id: 'highlight', label: 'Highlight', shortcut: 'K', icon: <HighlightIcon /> }, { 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: 'draw', label: 'Draw (ink)', shortcut: 'D', icon: <DrawIcon /> },
{ id: 'comment', label: 'Comment', shortcut: 'C', icon: <CommentIcon /> }, { id: 'comment', label: 'Comment', shortcut: 'C', icon: <CommentIcon /> },
{ id: 'textbox', label: 'Text box', shortcut: 'T', icon: <TextBoxIcon /> }, { id: 'textbox', label: 'Text box', shortcut: 'T', icon: <TextBoxIcon /> },
+25
View File
@@ -6,6 +6,7 @@ import { ColorSwatches, Slider } from './ui';
import { import {
SelectIcon, PanIcon, HighlightIcon, DrawIcon, CommentIcon, TextBoxIcon, SelectIcon, PanIcon, HighlightIcon, DrawIcon, CommentIcon, TextBoxIcon,
SignatureIcon, StampIcon, RedactIcon, SignatureIcon, StampIcon, RedactIcon,
UnderlineIcon, StrikeoutIcon, SquigglyIcon
} from './icons'; } from './icons';
interface ToolbarProps { interface ToolbarProps {
@@ -28,6 +29,9 @@ const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
signature: { label: 'Signature', icon: <SignatureIcon size={17} /> }, signature: { label: 'Signature', icon: <SignatureIcon size={17} /> },
stamp: { label: 'Stamp', icon: <StampIcon size={17} /> }, stamp: { label: 'Stamp', icon: <StampIcon size={17} /> },
redact: { label: 'Redact', icon: <RedactIcon 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' }) => ( const Hint: React.FC<{ children: React.ReactNode; tone?: 'normal' | 'warn' }> = ({ children, tone = 'normal' }) => (
@@ -77,6 +81,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' && ( {activeTool === 'draw' && (
<> <>
<Label>Color</Label> <Label>Color</Label>
+3
View File
@@ -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 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 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 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 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 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" /></>); 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" /></>);
+13 -1
View File
@@ -7,7 +7,10 @@ export type ToolId =
| 'textbox' | 'textbox'
| 'signature' | 'signature'
| 'stamp' | 'stamp'
| 'redact'; | 'redact'
| 'underline'
| 'strikeout'
| 'squiggly';
export interface ToolSettings { export interface ToolSettings {
highlightColor: string; highlightColor: string;
@@ -16,6 +19,9 @@ export interface ToolSettings {
inkThickness: number; inkThickness: number;
textColor: string; textColor: string;
fontSize: number; fontSize: number;
underlineColor: string;
strikeoutColor: string;
squigglyColor: string;
} }
export const DEFAULT_TOOL_SETTINGS: ToolSettings = { export const DEFAULT_TOOL_SETTINGS: ToolSettings = {
@@ -25,6 +31,9 @@ export const DEFAULT_TOOL_SETTINGS: ToolSettings = {
inkThickness: 2, inkThickness: 2,
textColor: '#1f2937', textColor: '#1f2937',
fontSize: 14, fontSize: 14,
underlineColor: '#2563eb',
strikeoutColor: '#dc2626',
squigglyColor: '#16a34a',
}; };
export const TOOL_SHORTCUTS: Record<string, ToolId> = { export const TOOL_SHORTCUTS: Record<string, ToolId> = {
@@ -37,6 +46,9 @@ export const TOOL_SHORTCUTS: Record<string, ToolId> = {
s: 'signature', s: 'signature',
m: 'stamp', m: 'stamp',
r: 'redact', r: 'redact',
u: 'underline',
x: 'strikeout',
w: 'squiggly',
}; };
export const STAMP_PRESETS = [ export const STAMP_PRESETS = [
+14 -3
View File
@@ -3,7 +3,7 @@ import type { Rect } from '../lib/coordinateMapping';
export interface Annotation { export interface Annotation {
id: string; id: string;
type: 'highlight' | 'signature' | 'strikeout' | 'comment' | 'ink' | 'widget'; type: 'highlight' | 'signature' | 'strikeout' | 'underline' | 'squiggly' | 'comment' | 'ink' | 'widget';
bbox: Rect; bbox: Rect;
color?: string; color?: string;
opacity?: number; opacity?: number;
@@ -88,7 +88,7 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
> >
{annotations {annotations
.filter((anno) => anno.pageIndex === undefined || anno.pageIndex === pageIndex) .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) => { .map((anno) => {
const scaledBbox = { const scaledBbox = {
x: anno.bbox.x * zoom, x: anno.bbox.x * zoom,
@@ -131,7 +131,18 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
</svg> </svg>
</div> </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' && ( {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)]"> <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"> <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">
+14 -4
View File
@@ -35,6 +35,7 @@ interface PDFViewerProps {
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void; onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void; onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
onPlaceSignature?: (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; onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
} }
@@ -74,6 +75,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
onPlaceText, onPlaceText,
onPlaceStamp, onPlaceStamp,
onPlaceSignature, onPlaceSignature,
onDecorateText,
onFieldChange, onFieldChange,
}, ref) => { }, ref) => {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@@ -264,7 +266,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
}, [visiblePages, documentId, verifiedPages]); }, [visiblePages, documentId, verifiedPages]);
// Fetch real glyph bounds for visible pages while text tools are active. // 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(() => { useEffect(() => {
if (!textToolActive || !documentId) return; if (!textToolActive || !documentId) return;
let active = true; let active = true;
@@ -285,7 +287,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
return () => { active = false; }; return () => { active = false; };
}, [textToolActive, visiblePages, documentId, pageTexts]); }, [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 (activeTool === 'select') {
if (text.trim()) { if (text.trim()) {
navigator.clipboard?.writeText(text).then( navigator.clipboard?.writeText(text).then(
@@ -314,6 +316,14 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
content: text, content: text,
}; };
onAnnotationAdded?.(newAnno); 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);
} }
}; };
@@ -399,14 +409,14 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
/> />
{/* Text Selection Dragging Layer */} {/* Text Selection Dragging Layer */}
{(activeTool === 'select' || activeTool === 'highlight') && ( {(activeTool === 'select' || activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly') && (
<SelectionLayer <SelectionLayer
pageIndex={page.index} pageIndex={page.index}
width={page.width} width={page.width}
height={page.height} height={page.height}
zoom={zoom} zoom={zoom}
glyphs={pageTexts[page.index] || []} glyphs={pageTexts[page.index] || []}
onTextSelected={(text, bbox) => handleTextSelection(text, bbox, page.index)} onTextSelected={(text, bbox, lines) => handleTextSelection(text, bbox, lines, page.index)}
/> />
)} )}
+21 -7
View File
@@ -8,7 +8,7 @@ interface SelectionLayerProps {
height: number; height: number;
zoom: number; zoom: number;
glyphs: Glyph[]; glyphs: Glyph[];
onTextSelected?: (text: string, bbox: Rect) => void; onTextSelected?: (text: string, bbox: Rect, lines: Rect[]) => void;
} }
interface ZGlyph { gx: number; gy: number; gw: number; gh: number; text: string } interface ZGlyph { gx: number; gy: number; gw: number; gh: number; text: string }
@@ -57,9 +57,8 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({ width, height, z
}); });
}; };
const buildText = (hits: ZGlyph[]): string => { const groupLines = (hits: ZGlyph[]): ZGlyph[][] => {
if (hits.length === 0) return ''; if (hits.length === 0) return [];
// Group into lines by vertical proximity, then order left→right.
const sorted = [...hits].sort((a, b) => a.gy - b.gy || a.gx - b.gx); const sorted = [...hits].sort((a, b) => a.gy - b.gy || a.gx - b.gx);
const lines: ZGlyph[][] = []; const lines: ZGlyph[][] = [];
for (const g of sorted) { for (const g of sorted) {
@@ -67,10 +66,13 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({ width, height, z
if (last && Math.abs(last[0].gy - g.gy) < last[0].gh * 0.6) last.push(g); if (last && Math.abs(last[0].gy - g.gy) < last[0].gh * 0.6) last.push(g);
else lines.push([g]); else lines.push([g]);
} }
return lines.map(line => line.sort((a, b) => a.gx - b.gx));
};
const buildText = (lines: ZGlyph[][]): string => {
return lines return lines
.map((line) => .map((line) =>
line line
.sort((a, b) => a.gx - b.gx)
.map((g, i, arr) => { .map((g, i, arr) => {
const prev = arr[i - 1]; const prev = arr[i - 1];
const gap = prev ? g.gx - (prev.gx + prev.gw) : 0; const gap = prev ? g.gx - (prev.gx + prev.gw) : 0;
@@ -92,11 +94,23 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({ width, height, z
const minY = Math.min(...hitGlyphs.map((g) => g.gy)); const minY = Math.min(...hitGlyphs.map((g) => g.gy));
const maxX = Math.max(...hitGlyphs.map((g) => g.gx + g.gw)); const maxX = Math.max(...hitGlyphs.map((g) => g.gx + g.gw));
const maxY = Math.max(...hitGlyphs.map((g) => g.gy + g.gh)); const maxY = Math.max(...hitGlyphs.map((g) => g.gy + g.gh));
onTextSelected?.(buildText(hitGlyphs), { x: minX, y: minY, width: maxX - minX, height: maxY - minY });
const lines = groupLines(hitGlyphs);
const text = buildText(lines);
const lineRects: Rect[] = lines.map(line => {
const lMinX = Math.min(...line.map(g => g.gx));
const lMinY = Math.min(...line.map(g => g.gy));
const lMaxX = Math.max(...line.map(g => g.gx + g.gw));
const lMaxY = Math.max(...line.map(g => g.gy + g.gh));
return { x: lMinX, y: lMinY, width: lMaxX - lMinX, height: lMaxY - lMinY };
});
onTextSelected?.(text, { x: minX, y: minY, width: maxX - minX, height: maxY - minY }, lineRects);
} else { } else {
// No glyph data (e.g. mock mode) — fall back to the raw drag box so the // No glyph data (e.g. mock mode) — fall back to the raw drag box so the
// highlight workflow still functions. // highlight workflow still functions.
onTextSelected?.('', selectionBox); onTextSelected?.('', selectionBox, [selectionBox]);
} }
} }
setDragStart(null); setDragStart(null);
+29 -1
View File
@@ -222,6 +222,31 @@ class ReplaceTextOperation(BaseModel):
data: ReplaceTextData 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[ EditOperation = Annotated[
TextOverlayOperation TextOverlayOperation
| RedactionOperation | RedactionOperation
@@ -236,7 +261,10 @@ EditOperation = Annotated[
| UpdateFieldOperation | UpdateFieldOperation
| DeleteAnnotationOperation | DeleteAnnotationOperation
| UpdateAnnotationOperation | UpdateAnnotationOperation
| ReplaceTextOperation, | ReplaceTextOperation
| UnderlineOperation
| StrikeoutOperation
| SquigglyOperation,
Field(discriminator="type"), Field(discriminator="type"),
] ]