This commit is contained in:
Furqan-14
2026-06-12 11:31:40 +05:30
13 changed files with 296 additions and 14 deletions
+1
View File
@@ -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
+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
@@ -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()) {
+24
View File
@@ -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]">
+4
View File
@@ -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 /> },
+25
View File
@@ -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>
+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 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" /></>);
+13 -1
View File
@@ -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 = [
+14 -3
View File
@@ -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">
+15 -5
View File
@@ -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)}
/>
)}
+6 -4
View File
@@ -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.
+29 -1
View File
@@ -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"),
]