From 4d900c3d0ccd8e188b6c2914a82767a25379e8c2 Mon Sep 17 00:00:00 2001 From: saqib mir Date: Thu, 11 Jun 2026 19:30:27 +0530 Subject: [PATCH] Underline + decoration renderer (DecorationBuilder) --- engine/CMakeLists.txt | 1 + engine/src/parser/decoration_builder.cpp | 55 +++++++++++++++++ engine/src/parser/decoration_builder.hpp | 32 ++++++++++ engine/src/parser/pdfium_document.cpp | 75 ++++++++++++++++++++++++ frontend/src/App.tsx | 24 ++++++++ frontend/src/components/ToolRail.tsx | 4 ++ frontend/src/components/Toolbar.tsx | 25 ++++++++ frontend/src/components/icons.tsx | 3 + frontend/src/lib/tools.ts | 14 ++++- frontend/src/viewer/AnnotationLayer.tsx | 17 +++++- frontend/src/viewer/PDFViewer.tsx | 18 ++++-- frontend/src/viewer/SelectionLayer.tsx | 28 ++++++--- gateway/app/routers/edits.py | 30 +++++++++- 13 files changed, 310 insertions(+), 16 deletions(-) create mode 100644 engine/src/parser/decoration_builder.cpp create mode 100644 engine/src/parser/decoration_builder.hpp diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 9be9566..f870f9f 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -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/fonts/face/font_face.cpp src/fonts/face/free_type_manager.cpp src/fonts/loader/font_resolver.cpp diff --git a/engine/src/parser/decoration_builder.cpp b/engine/src/parser/decoration_builder.cpp new file mode 100644 index 0000000..c53ab53 --- /dev/null +++ b/engine/src/parser/decoration_builder.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 diff --git a/engine/src/parser/decoration_builder.hpp b/engine/src/parser/decoration_builder.hpp new file mode 100644 index 0000000..d03dc41 --- /dev/null +++ b/engine/src/parser/decoration_builder.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include + +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 diff --git a/engine/src/parser/pdfium_document.cpp b/engine/src/parser/pdfium_document.cpp index d13c7be..0406428 100644 --- a/engine/src/parser/pdfium_document.cpp +++ b/engine/src/parser/pdfium_document.cpp @@ -14,6 +14,7 @@ #include "fonts/pdf_fonts/font.hpp" #include "fonts/pdf_fonts/font_fallback.hpp" #include "fonts/shaping/hb_shaper.hpp" +#include "decoration_builder.hpp" #include #include @@ -2190,6 +2191,80 @@ std::expected 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()) { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1315da9..10df7bd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -262,6 +262,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([{ @@ -532,6 +555,7 @@ function App() { onPlaceText={handlePlaceText} onPlaceStamp={handlePlaceStamp} onPlaceSignature={handlePlaceSignature} + onDecorateText={handleDecorateText} /> ) : (
diff --git a/frontend/src/components/ToolRail.tsx b/frontend/src/components/ToolRail.tsx index f157f20..1829606 100644 --- a/frontend/src/components/ToolRail.tsx +++ b/frontend/src/components/ToolRail.tsx @@ -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: }, 'divider', { id: 'highlight', label: 'Highlight', shortcut: 'K', icon: }, + { id: 'underline', label: 'Underline', shortcut: 'U', icon: }, + { id: 'strikeout', label: 'Strikeout', shortcut: 'X', icon: }, + { id: 'squiggly', label: 'Squiggly', shortcut: 'W', icon: }, { id: 'draw', label: 'Draw (ink)', shortcut: 'D', icon: }, { id: 'comment', label: 'Comment', shortcut: 'C', icon: }, { id: 'textbox', label: 'Text box', shortcut: 'T', icon: }, diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index 12e537b..c3c17ba 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -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 { @@ -28,6 +29,9 @@ const TOOL_META: Record = { signature: { label: 'Signature', icon: }, stamp: { label: 'Stamp', icon: }, redact: { label: 'Redact', icon: }, + underline: { label: 'Underline', icon: }, + strikeout: { label: 'Strikeout', icon: }, + squiggly: { label: 'Squiggly', icon: }, }; const Hint: React.FC<{ children: React.ReactNode; tone?: 'normal' | 'warn' }> = ({ children, tone = 'normal' }) => ( @@ -77,6 +81,27 @@ export const Toolbar: React.FC = ({ )} + {activeTool === 'underline' && ( + <> + + onSettingsChange({ underlineColor: c })} /> + + )} + + {activeTool === 'strikeout' && ( + <> + + onSettingsChange({ strikeoutColor: c })} /> + + )} + + {activeTool === 'squiggly' && ( + <> + + onSettingsChange({ squigglyColor: c })} /> + + )} + {activeTool === 'draw' && ( <> diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx index 68160f5..0e6969e 100644 --- a/frontend/src/components/icons.tsx +++ b/frontend/src/components/icons.tsx @@ -41,6 +41,9 @@ export const DrawIcon: IC = mk(); export const SignatureIcon: IC = mk(<>); export const TextBoxIcon: IC = mk(<>); +export const UnderlineIcon: IC = mk(<>); +export const StrikeoutIcon: IC = mk(<>); +export const SquigglyIcon: IC = mk(); export const StampIcon: IC = mk(<>); export const RedactIcon: IC = mk(); export const ImageIcon: IC = mk(<>); diff --git a/frontend/src/lib/tools.ts b/frontend/src/lib/tools.ts index 6d1b514..c0dc91f 100644 --- a/frontend/src/lib/tools.ts +++ b/frontend/src/lib/tools.ts @@ -7,7 +7,10 @@ export type ToolId = | 'textbox' | 'signature' | 'stamp' - | 'redact'; + | 'redact' + | 'underline' + | 'strikeout' + | 'squiggly'; export interface ToolSettings { highlightColor: string; @@ -16,6 +19,9 @@ export interface ToolSettings { inkThickness: number; textColor: string; fontSize: number; + underlineColor: string; + strikeoutColor: string; + squigglyColor: string; } export const DEFAULT_TOOL_SETTINGS: ToolSettings = { @@ -25,6 +31,9 @@ export const DEFAULT_TOOL_SETTINGS: ToolSettings = { inkThickness: 2, textColor: '#1f2937', fontSize: 14, + underlineColor: '#2563eb', + strikeoutColor: '#dc2626', + squigglyColor: '#16a34a', }; export const TOOL_SHORTCUTS: Record = { @@ -37,6 +46,9 @@ export const TOOL_SHORTCUTS: Record = { s: 'signature', m: 'stamp', r: 'redact', + u: 'underline', + x: 'strikeout', + w: 'squiggly', }; export const STAMP_PRESETS = [ diff --git a/frontend/src/viewer/AnnotationLayer.tsx b/frontend/src/viewer/AnnotationLayer.tsx index a591c4e..00045d4 100644 --- a/frontend/src/viewer/AnnotationLayer.tsx +++ b/frontend/src/viewer/AnnotationLayer.tsx @@ -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 = ({ > {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 = ({
)} - {anno.type === 'strikeout' &&
} + {anno.type === 'strikeout' &&
} + {anno.type === 'underline' &&
} + {anno.type === 'squiggly' && ( + + + + + + + + + )} {anno.type === 'signature' && (
diff --git a/frontend/src/viewer/PDFViewer.tsx b/frontend/src/viewer/PDFViewer.tsx index 45a1b96..67015fe 100644 --- a/frontend/src/viewer/PDFViewer.tsx +++ b/frontend/src/viewer/PDFViewer.tsx @@ -35,6 +35,7 @@ interface PDFViewerProps { onPlaceText?: (pageIndex: number, rectPts: Rect, text: 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; } @@ -74,6 +75,7 @@ export const PDFViewer = React.forwardRef(({ onPlaceText, onPlaceStamp, onPlaceSignature, + onDecorateText, onFieldChange, }, ref) => { const containerRef = useRef(null); @@ -264,7 +266,7 @@ export const PDFViewer = React.forwardRef(({ }, [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; @@ -285,7 +287,7 @@ export const PDFViewer = React.forwardRef(({ 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( @@ -314,6 +316,14 @@ export const PDFViewer = React.forwardRef(({ 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); } }; @@ -399,14 +409,14 @@ export const PDFViewer = React.forwardRef(({ /> {/* Text Selection Dragging Layer */} - {(activeTool === 'select' || activeTool === 'highlight') && ( + {(activeTool === 'select' || activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly') && ( handleTextSelection(text, bbox, page.index)} + onTextSelected={(text, bbox, lines) => handleTextSelection(text, bbox, lines, page.index)} /> )} diff --git a/frontend/src/viewer/SelectionLayer.tsx b/frontend/src/viewer/SelectionLayer.tsx index f0128a4..897e689 100644 --- a/frontend/src/viewer/SelectionLayer.tsx +++ b/frontend/src/viewer/SelectionLayer.tsx @@ -8,7 +8,7 @@ interface SelectionLayerProps { height: number; zoom: number; 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 } @@ -57,9 +57,8 @@ export const SelectionLayer: React.FC = ({ width, height, z }); }; - const buildText = (hits: ZGlyph[]): string => { - if (hits.length === 0) return ''; - // Group into lines by vertical proximity, then order left→right. + const groupLines = (hits: ZGlyph[]): ZGlyph[][] => { + if (hits.length === 0) return []; const sorted = [...hits].sort((a, b) => a.gy - b.gy || a.gx - b.gx); const lines: ZGlyph[][] = []; for (const g of sorted) { @@ -67,10 +66,13 @@ export const SelectionLayer: React.FC = ({ width, height, z if (last && Math.abs(last[0].gy - g.gy) < last[0].gh * 0.6) last.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 .map((line) => line - .sort((a, b) => a.gx - b.gx) .map((g, i, arr) => { const prev = arr[i - 1]; const gap = prev ? g.gx - (prev.gx + prev.gw) : 0; @@ -92,11 +94,23 @@ export const SelectionLayer: React.FC = ({ width, height, z const minY = Math.min(...hitGlyphs.map((g) => g.gy)); const maxX = Math.max(...hitGlyphs.map((g) => g.gx + g.gw)); 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 { // No glyph data (e.g. mock mode) — fall back to the raw drag box so the // highlight workflow still functions. - onTextSelected?.('', selectionBox); + onTextSelected?.('', selectionBox, [selectionBox]); } } setDragStart(null); diff --git a/gateway/app/routers/edits.py b/gateway/app/routers/edits.py index d2ecd6d..612ebf3 100644 --- a/gateway/app/routers/edits.py +++ b/gateway/app/routers/edits.py @@ -222,6 +222,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 @@ -236,7 +261,10 @@ EditOperation = Annotated[ | UpdateFieldOperation | DeleteAnnotationOperation | UpdateAnnotationOperation - | ReplaceTextOperation, + | ReplaceTextOperation + | UnderlineOperation + | StrikeoutOperation + | SquigglyOperation, Field(discriminator="type"), ]