Underline + decoration renderer (DecorationBuilder)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
@@ -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 /> },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" /></>);
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user