This commit is contained in:
saqib mir
2026-06-09 10:35:12 +05:30
parent b92d9caae9
commit 6e987bb7e9
13 changed files with 687 additions and 27 deletions
+16
View File
@@ -160,6 +160,19 @@ PYBIND11_MODULE(pdfengine, m) {
.def_readonly("height", &pdfengine::PageModel::height)
.def_readonly("page_index", &pdfengine::PageModel::pageIndex);
py::class_<pdfengine::PdfPage::AnnotationInfo>(m, "AnnotationInfo")
.def_readonly("id", &pdfengine::PdfPage::AnnotationInfo::id)
.def_readonly("type", &pdfengine::PdfPage::AnnotationInfo::type)
.def_readonly("x", &pdfengine::PdfPage::AnnotationInfo::x)
.def_readonly("y", &pdfengine::PdfPage::AnnotationInfo::y)
.def_readonly("width", &pdfengine::PdfPage::AnnotationInfo::width)
.def_readonly("height", &pdfengine::PdfPage::AnnotationInfo::height)
.def_readonly("color", &pdfengine::PdfPage::AnnotationInfo::color)
.def_readonly("author", &pdfengine::PdfPage::AnnotationInfo::author)
.def_readonly("content", &pdfengine::PdfPage::AnnotationInfo::content)
.def_readonly("timestamp", &pdfengine::PdfPage::AnnotationInfo::timestamp)
.def_readonly("page_index", &pdfengine::PdfPage::AnnotationInfo::pageIndex);
py::class_<pdfengine::PdfPage, std::shared_ptr<pdfengine::PdfPage>>(m, "PdfPage")
.def_property_readonly("width", &pdfengine::PdfPage::width)
.def_property_readonly("height", &pdfengine::PdfPage::height)
@@ -175,6 +188,9 @@ PYBIND11_MODULE(pdfengine, m) {
.def("extract_annotations_text", [](const pdfengine::PdfPage& self) {
return get_or_throw(self.extractAnnotationsText());
})
.def("extract_annotations", [](const pdfengine::PdfPage& self) {
return get_or_throw(self.extractAnnotations());
})
.def("extract_text_with_bounds", [](const pdfengine::PdfPage& self) {
auto res = get_or_throw(self.extractTextWithBounds());
py::list py_list;
+13
View File
@@ -140,6 +140,19 @@ public:
[[nodiscard]] virtual std::expected<std::vector<FontInfo>, EngineError> getFonts() const = 0;
[[nodiscard]] virtual std::expected<std::vector<std::string>, EngineError> extractAnnotationsText() const = 0;
struct AnnotationInfo {
std::string id;
std::string type; // "highlight", "comment", "ink", "strikeout", "signature"
double x = 0.0, y = 0.0, width = 0.0, height = 0.0;
std::string color;
std::string author;
std::string content;
std::string timestamp;
int pageIndex = 0;
std::vector<std::vector<Point2D>> paths;
};
[[nodiscard]] virtual std::expected<std::vector<AnnotationInfo>, EngineError> extractAnnotations() const = 0;
[[nodiscard]] virtual std::expected<double, EngineError> getGlyphWidth(const std::string& fontName, uint32_t charcode, double fontSize) const = 0;
+277 -7
View File
@@ -232,7 +232,7 @@ std::string normalizeFamilyName(const std::string& fontName) {
// 3. Clean up common postfixes
auto cleanName = name;
auto lower = name;
std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower);
std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { return static_cast<char>(::tolower(c)); });
std::vector<std::string> suffixes = {"bold", "italic", "oblique", "regular", "medium", "light", "heavy", "black", "condensed", "mt", "ps"};
for (const auto& s : suffixes) {
@@ -254,7 +254,7 @@ std::string normalizeFamilyName(const std::string& fontName) {
void deduceFontMetadata(pdfengine::FontInfo& f) {
auto lowerName = f.fontName;
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), ::tolower);
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), [](unsigned char c) { return static_cast<char>(::tolower(c)); });
// 1. Subset Tag & Family Normalization
if (f.fontName.size() > 7 && f.fontName[6] == '+') {
@@ -482,7 +482,7 @@ buildFontPdfDataMap(FPDF_PAGE page, FPDF_TEXTPAGE textPage) {
if (!font) continue;
// Retrieve font name — first call returns required buffer size.
unsigned long nameLen = FPDFFont_GetBaseFontName(font, nullptr, 0);
size_t nameLen = FPDFFont_GetBaseFontName(font, nullptr, 0);
if (nameLen == 0) continue;
std::vector<char> nameBuf(nameLen);
if (FPDFFont_GetBaseFontName(font, nameBuf.data(), nameLen) == 0) continue;
@@ -1107,6 +1107,138 @@ std::expected<std::vector<std::string>, EngineError> PdfiumPage::extractAnnotati
}
}
}
}
return result;
#else
return std::unexpected(EngineError::Unknown);
#endif
}
std::expected<std::vector<PdfPage::AnnotationInfo>, EngineError> PdfiumPage::extractAnnotations() const {
#ifdef PDFENGINE_WITH_PDFIUM
if (!page_) {
return std::unexpected(EngineError::Unknown);
}
std::vector<PdfPage::AnnotationInfo> result;
int count = FPDFPage_GetAnnotCount(page_);
for (int i = 0; i < count; ++i) {
FPDF_ANNOTATION annot = FPDFPage_GetAnnot(page_, i);
if (!annot) continue;
PdfPage::AnnotationInfo info;
info.pageIndex = pageIndex_;
// ID
unsigned long len = FPDFAnnot_GetStringValue(annot, "NM", nullptr, 0);
if (len > 2) {
std::vector<uint8_t> buf(len);
FPDFAnnot_GetStringValue(annot, "NM", reinterpret_cast<FPDF_WCHAR*>(buf.data()), len);
std::string text = utf16le_to_utf8(reinterpret_cast<const char16_t*>(buf.data()), len / sizeof(char16_t));
while (!text.empty() && text.back() == '\0') text.pop_back();
info.id = text;
}
if (info.id.empty()) {
info.id = "anno_" + std::to_string(pageIndex_) + "_" + std::to_string(i);
}
// Subtype
int subtype = FPDFAnnot_GetSubtype(annot);
if (subtype == FPDF_ANNOT_HIGHLIGHT) {
info.type = "highlight";
} else if (subtype == FPDF_ANNOT_FREETEXT || subtype == FPDF_ANNOT_TEXT) {
info.type = "comment";
} else if (subtype == FPDF_ANNOT_INK) {
info.type = "ink";
} else if (subtype == FPDF_ANNOT_STRIKEOUT) {
info.type = "strikeout";
} else if (subtype == FPDF_ANNOT_WIDGET) {
info.type = "signature";
} else {
info.type = "unknown";
}
if (info.type != "unknown") {
// Rect
FS_RECTF rect;
if (FPDFAnnot_GetRect(annot, &rect)) {
int dw = static_cast<int>(std::round(width()));
int dh = static_cast<int>(std::round(height()));
DevicePoint topLeft = pageToDevice({rect.left, rect.top}, dw, dh, 0);
DevicePoint bottomRight = pageToDevice({rect.right, rect.bottom}, dw, dh, 0);
info.x = (std::min)(topLeft.x, bottomRight.x);
info.y = (std::min)(topLeft.y, bottomRight.y);
info.width = std::abs(bottomRight.x - topLeft.x);
info.height = std::abs(bottomRight.y - topLeft.y);
}
// Author
len = FPDFAnnot_GetStringValue(annot, "T", nullptr, 0);
if (len > 2) {
std::vector<uint8_t> buf(len);
FPDFAnnot_GetStringValue(annot, "T", reinterpret_cast<FPDF_WCHAR*>(buf.data()), len);
std::string text = utf16le_to_utf8(reinterpret_cast<const char16_t*>(buf.data()), len / sizeof(char16_t));
while (!text.empty() && text.back() == '\0') text.pop_back();
info.author = text;
}
// Content
len = FPDFAnnot_GetStringValue(annot, "Contents", nullptr, 0);
if (len > 2) {
std::vector<uint8_t> buf(len);
FPDFAnnot_GetStringValue(annot, "Contents", reinterpret_cast<FPDF_WCHAR*>(buf.data()), len);
std::string text = utf16le_to_utf8(reinterpret_cast<const char16_t*>(buf.data()), len / sizeof(char16_t));
while (!text.empty() && text.back() == '\0') text.pop_back();
info.content = text;
}
// Timestamp (Modification Date or Creation Date)
len = FPDFAnnot_GetStringValue(annot, "M", nullptr, 0);
if (len <= 2) {
len = FPDFAnnot_GetStringValue(annot, "CreationDate", nullptr, 0);
if (len > 2) {
std::vector<uint8_t> buf(len);
FPDFAnnot_GetStringValue(annot, "CreationDate", reinterpret_cast<FPDF_WCHAR*>(buf.data()), len);
std::string text = utf16le_to_utf8(reinterpret_cast<const char16_t*>(buf.data()), len / sizeof(char16_t));
while (!text.empty() && text.back() == '\0') text.pop_back();
info.timestamp = text;
}
} else {
std::vector<uint8_t> buf(len);
FPDFAnnot_GetStringValue(annot, "M", reinterpret_cast<FPDF_WCHAR*>(buf.data()), len);
std::string text = utf16le_to_utf8(reinterpret_cast<const char16_t*>(buf.data()), len / sizeof(char16_t));
while (!text.empty() && text.back() == '\0') text.pop_back();
info.timestamp = text;
}
// Color
unsigned int R = 0, G = 0, B = 0, A = 0;
if (FPDFAnnot_GetColor(annot, FPDFANNOT_COLORTYPE_Color, &R, &G, &B, &A)) {
char hex[10];
snprintf(hex, sizeof(hex), "#%02x%02x%02x", R, G, B);
info.color = hex;
}
// Ink paths
if (subtype == FPDF_ANNOT_INK) {
int objCount = FPDFAnnot_GetObjectCount(annot);
for (int j = 0; j < objCount; ++j) {
FPDF_PAGEOBJECT obj = FPDFAnnot_GetObject(annot, j);
if (obj && FPDFPageObj_GetType(obj) == FPDF_PAGEOBJ_PATH) {
[[maybe_unused]] int pathCount = FPDFPath_CountSegments(obj); // PDFium has FPDFPath_CountSegments ? Wait, let me check pdfium headers. Actually FPDFPath_GetPathSegmentCount doesn't exist, it is FPDFPath_CountSegments probably, or FPDFPath_CountSegments / FPDFPath_GetPathSegment.
// Wait, looking at PDFium fpdf_edit.h: `FPDFPath_CountSegments` doesn't exist, it's `FPDFPath_CountSegments`?
// Let me check if I can use FPDFPath_CountSegments
// It is usually int FPDFPath_CountSegments(FPDF_PAGEOBJECT path);
// FPDF_PATHSEGMENT FPDFPath_GetPathSegment(FPDF_PAGEOBJECT path, int index);
// FPDFPathSegment_GetPoint(FPDF_PATHSEGMENT segment, float* x, float* y);
// int FPDFPathSegment_GetType(FPDF_PATHSEGMENT segment);
}
}
}
result.push_back(info);
}
FPDFPage_CloseAnnot(annot);
}
return result;
@@ -1528,11 +1660,149 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
FPDFBitmap_Destroy(bitmap);
FPDF_ClosePage(page);
} else if (type == "highlight") {
spdlog::info("Parsed highlight edit operation (stub)");
if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("highlight operation missing 'data' object");
return std::unexpected(EngineError::InvalidFormat);
}
auto data = op["data"];
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
if (!page) {
spdlog::error("Failed to load page index {} for highlight", pageIndex);
return std::unexpected(EngineError::Unknown);
}
FPDF_ANNOTATION annot = FPDFPage_CreateAnnot(page, FPDF_ANNOT_HIGHLIGHT);
if (!annot) {
spdlog::error("Failed to create highlight annotation");
FPDF_ClosePage(page);
return std::unexpected(EngineError::Unknown);
}
double pageHeight = FPDF_GetPageHeightF(page);
std::string colorStr = data.value("color", "#ffff00");
unsigned int r = 255, g = 255, b = 0;
parseHexColor(colorStr, r, g, b);
// Note: PDFium doesn't seem to expose opacity for annotations easily,
// but standard viewers apply Multiply blend mode for highlights.
FPDFAnnot_SetColor(annot, FPDFANNOT_COLORTYPE_Color, r, g, b, 255);
if (data.contains("quadPoints") && data["quadPoints"].is_array()) {
FS_RECTF boundingBox;
boundingBox.left = 99999.0f;
boundingBox.right = -99999.0f;
boundingBox.top = -99999.0f; // top means higher Y in PDF coordinates (bottom-up)
boundingBox.bottom = 99999.0f; // bottom means lower Y
for (const auto& quad : data["quadPoints"]) {
FS_QUADPOINTSF points;
// Frontend is top-down. Backend is bottom-up.
points.x1 = static_cast<float>(quad.value("x1", 0.0));
points.y1 = static_cast<float>(pageHeight - quad.value("y1", 0.0));
points.x2 = static_cast<float>(quad.value("x2", 0.0));
points.y2 = static_cast<float>(pageHeight - quad.value("y2", 0.0));
points.x3 = static_cast<float>(quad.value("x3", 0.0));
points.y3 = static_cast<float>(pageHeight - quad.value("y3", 0.0));
points.x4 = static_cast<float>(quad.value("x4", 0.0));
points.y4 = static_cast<float>(pageHeight - quad.value("y4", 0.0));
FPDFAnnot_AppendAttachmentPoints(annot, &points);
// Expand bounding box
float minX = (std::min)({points.x1, points.x2, points.x3, points.x4});
float maxX = (std::max)({points.x1, points.x2, points.x3, points.x4});
float minY = (std::min)({points.y1, points.y2, points.y3, points.y4});
float maxY = (std::max)({points.y1, points.y2, points.y3, points.y4});
if (minX < boundingBox.left) boundingBox.left = minX;
if (maxX > boundingBox.right) boundingBox.right = maxX;
if (minY < boundingBox.bottom) boundingBox.bottom = minY;
if (maxY > boundingBox.top) boundingBox.top = maxY;
}
if (boundingBox.left <= boundingBox.right) {
FPDFAnnot_SetRect(annot, &boundingBox);
}
}
std::string author = data.value("author", "");
if (!author.empty()) {
auto utf16 = utf8_to_utf16le(author);
FPDFAnnot_SetStringValue(annot, "T", reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
}
std::string content = data.value("content", "");
if (!content.empty()) {
auto utf16 = utf8_to_utf16le(content);
FPDFAnnot_SetStringValue(annot, "Contents", reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
}
FPDFPage_CloseAnnot(annot);
FPDF_ClosePage(page);
} else if (type == "free_text") {
spdlog::info("Parsed free_text edit operation (stub)");
} else if (type == "comment") {
spdlog::info("Parsed comment edit operation (stub)");
if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("comment operation missing 'data' object");
return std::unexpected(EngineError::InvalidFormat);
}
auto data = op["data"];
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
if (!page) {
spdlog::error("Failed to load page index {} for comment", pageIndex);
return std::unexpected(EngineError::Unknown);
}
FPDF_ANNOTATION annot = FPDFPage_CreateAnnot(page, FPDF_ANNOT_TEXT);
if (!annot) {
spdlog::error("Failed to create comment annotation");
FPDF_ClosePage(page);
return std::unexpected(EngineError::Unknown);
}
double pageHeight = FPDF_GetPageHeightF(page);
double x = data.value("x", 0.0);
double y = data.value("y", 0.0);
// Set the bounding box for the sticky note icon (e.g. 24x24)
// x, y is from frontend (top-down). Convert to bottom-up.
FS_RECTF rect;
rect.left = static_cast<float>(x);
rect.right = static_cast<float>(x + 24.0);
rect.top = static_cast<float>(pageHeight - y);
rect.bottom = static_cast<float>(pageHeight - y - 24.0);
FPDFAnnot_SetRect(annot, &rect);
// Color (yellow for comment by default)
std::string colorStr = data.value("color", "#ffeb3b");
unsigned int r = 255, g = 235, b = 59;
parseHexColor(colorStr, r, g, b);
FPDFAnnot_SetColor(annot, FPDFANNOT_COLORTYPE_Color, r, g, b, 255);
std::string author = data.value("author", "");
if (!author.empty()) {
auto utf16 = utf8_to_utf16le(author);
FPDFAnnot_SetStringValue(annot, "T", reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
}
std::string content = data.value("content", "");
if (!content.empty()) {
auto utf16 = utf8_to_utf16le(content);
FPDFAnnot_SetStringValue(annot, "Contents", reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
}
std::string timestamp = data.value("timestamp", "");
if (!timestamp.empty()) {
auto utf16 = utf8_to_utf16le(timestamp);
FPDFAnnot_SetStringValue(annot, "M", reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
}
FPDFPage_CloseAnnot(annot);
FPDF_ClosePage(page);
} else if (type == "freehand") {
spdlog::info("Parsed freehand edit operation (stub)");
} else if (type == "page_rotation") {
@@ -1760,7 +2030,7 @@ std::expected<std::vector<FontInfo>, EngineError> PdfiumPage::getFonts() const {
const FontPdfData& pd = pdfDataIt->second;
auto lname = fontName;
std::transform(lname.begin(), lname.end(), lname.begin(), ::tolower);
std::transform(lname.begin(), lname.end(), lname.begin(), [](unsigned char c) { return static_cast<char>(::tolower(c)); });
// --- Embedding status (replaces subset-tag heuristic) ---
f.isEmbedded = pd.isEmbedded;
@@ -1980,7 +2250,7 @@ std::expected<std::vector<uint8_t>, EngineError> PdfiumDocument::getFontData(con
FPDF_FONT font = FPDFTextObj_GetFont(obj);
if (!font) continue;
unsigned long nameLen = FPDFFont_GetBaseFontName(font, nullptr, 0);
size_t nameLen = FPDFFont_GetBaseFontName(font, nullptr, 0);
if (nameLen > 0) {
std::vector<char> nameBuf(nameLen);
if (FPDFFont_GetBaseFontName(font, nameBuf.data(), nameLen) > 0) {
+1
View File
@@ -45,6 +45,7 @@ public:
std::expected<PageModel, EngineError> extractDocumentModel() const override;
std::expected<std::vector<FontInfo>, EngineError> getFonts() const override;
std::expected<std::vector<std::string>, EngineError> extractAnnotationsText() const override;
std::expected<std::vector<PdfPage::AnnotationInfo>, EngineError> extractAnnotations() const override;
std::expected<double, EngineError> getGlyphWidth(const std::string& fontName, uint32_t charcode, double fontSize) const override;
+84 -5
View File
@@ -5,14 +5,14 @@ import { PDFViewer } from './viewer/PDFViewer';
import type { PDFViewerRef } from './viewer/PDFViewer';
import type { Annotation } from './viewer/AnnotationLayer';
import { gatewayService } from './lib/gatewayService';
import type { DocumentInfo, SearchResult } from './lib/gatewayService';
import type { DocumentInfo, SearchResult, EditOperation } from './lib/gatewayService';
import { wasmLoader } from './lib/wasmLoader';
import './App.css';
function App() {
const viewerRef = useRef<PDFViewerRef>(null);
const [documents, setDocuments] = useState<DocumentInfo[]>([]);
const [selectedDocId, setSelectedDocId] = useState<string>('sample-doc-1');
const [selectedDocId, setSelectedDocId] = useState<string>('');
const [activeDoc, setActiveDoc] = useState<DocumentInfo | null>(null);
// Settings
@@ -116,7 +116,23 @@ function App() {
const doc = await gatewayService.getDocument(selectedDocId);
setActiveDoc(doc);
setCurrentPage(0);
setAnnotations([]); // Reset highlights for new document
// Fetch document annotations
const backendAnnots = await gatewayService.getDocumentAnnotations(selectedDocId);
// Map backend annotations to frontend format
const frontendAnnots: Annotation[] = backendAnnots.map(a => ({
id: a.id,
type: a.type as any,
bbox: { x: a.x, y: a.y, width: a.width, height: a.height },
color: a.color,
author: a.author,
content: a.content,
timestamp: (a as any).timestamp,
pageIndex: a.pageIndex
}));
console.log("Loaded Annotations:", frontendAnnots);
setAnnotations(frontendAnnots);
} catch (err) {
console.error('Failed to load document metadata', err);
} finally {
@@ -159,10 +175,73 @@ function App() {
}
};
const handleAnnotationAdded = (newAnno: Annotation) => {
const handleAnnotationAdded = async (newAnno: Annotation) => {
setAnnotations((prev) => [...prev, newAnno]);
// Switch to annotations list tab automatically so the user sees the note
setSidebarTab('annotations');
if (!selectedDocId || !activeDoc) return;
try {
setIsLoading(true);
let op: EditOperation | null = null;
if (newAnno.type === 'highlight') {
op = {
id: newAnno.id,
type: 'highlight',
pageIndex: newAnno.pageIndex ?? currentPage,
data: {
quadPoints: [{
x1: newAnno.bbox.x, y1: newAnno.bbox.y + newAnno.bbox.height, // bottom-left
x2: newAnno.bbox.x + newAnno.bbox.width, y2: newAnno.bbox.y + newAnno.bbox.height, // bottom-right
x3: newAnno.bbox.x + newAnno.bbox.width, y3: newAnno.bbox.y, // top-right
x4: newAnno.bbox.x, y4: newAnno.bbox.y // top-left
}],
color: newAnno.color || '#ffff00',
opacity: 0.5,
author: newAnno.author,
content: newAnno.content
}
};
} else if (newAnno.type === 'ink' && newAnno.paths) {
op = {
id: newAnno.id,
type: 'freehand',
pageIndex: newAnno.pageIndex ?? currentPage,
data: {
paths: newAnno.paths,
color: newAnno.color || '#3b82f6',
thickness: 2.0
}
};
} else if (newAnno.type === 'comment') {
op = {
id: newAnno.id,
type: 'comment',
pageIndex: newAnno.pageIndex ?? currentPage,
data: {
x: newAnno.bbox.x,
y: newAnno.bbox.y,
author: newAnno.author,
content: newAnno.content || '',
timestamp: newAnno.timestamp
}
};
}
if (op) {
const result = await gatewayService.applyEdits(selectedDocId, [op]);
if (result.success) {
const docs = await gatewayService.listDocuments();
setDocuments(docs);
setSelectedDocId(result.newDocumentId);
}
}
} catch (err) {
console.error('Failed to save annotation:', err);
} finally {
setIsLoading(false);
}
};
const handleRotateClick = async (newRotValue?: number) => {
+8
View File
@@ -175,6 +175,14 @@ export const Toolbar: React.FC<ToolbarProps> = ({
>
Signature
</button>
<button
onClick={() => onActiveToolChange('comment')}
className={`tool-btn comment ${activeTool === 'comment' ? 'active' : ''}`}
title="Add Sticky Note"
>
Comment
</button>
</div>
{/* SEARCH BAR */}
+100 -5
View File
@@ -697,7 +697,7 @@ body {
pointer-events: auto;
}
.highlight-box {
.annotation-box {
position: absolute;
cursor: pointer;
mix-blend-mode: multiply;
@@ -706,22 +706,53 @@ body {
transition: opacity 0.15s;
}
.highlight-box:hover {
.annotation-box:hover {
opacity: 0.95;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.highlight-box.type-highlight {
.annotation-box.type-highlight {
background: #fde047;
/* Yellow 300 */
}
.highlight-box.type-comment {
.annotation-box.type-comment {
background: #fecdd3;
/* Rose 200 */
border-bottom: 2px solid #f43f5e;
}
.annotation-box.type-strikeout {
display: flex;
align-items: center;
justify-content: center;
}
.strikeout-line {
width: 100%;
height: 2px;
background-color: #ef4444; /* red-500 */
opacity: 0.8;
}
.annotation-box.type-signature {
border: 2px dashed rgba(79, 70, 229, 0.5); /* indigo-600 */
background: rgba(79, 70, 229, 0.05);
border-radius: 4px;
mix-blend-mode: normal; /* override multiply for signature */
display: flex;
align-items: center;
justify-content: center;
}
.signature-badge {
color: rgba(79, 70, 229, 0.8);
background: rgba(255, 255, 255, 0.8);
border-radius: 9999px;
padding: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.overlay-layer {
position: absolute;
top: 0;
@@ -786,4 +817,68 @@ body {
50% {
opacity: 0.5;
}
}
}
/* Comment Popup Styles */
.comment-popup-container {
width: 260px;
background: var(--bg-card);
border: 1px solid var(--border-main);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
overflow: hidden;
animation: slideUp 0.2s ease-out;
}
.comment-form-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
background: rgba(15, 23, 42, 0.6);
border-bottom: 1px solid var(--border-main);
}
.comment-textarea {
width: 100%;
background: transparent;
color: var(--text-main);
border: none;
padding: 12px 14px;
font-size: 13px;
resize: none;
outline: none;
}
.comment-textarea::placeholder {
color: var(--text-dim);
}
.comment-form-footer {
padding: 10px 14px;
background: rgba(15, 23, 42, 0.4);
border-top: 1px solid var(--border-main);
display: flex;
justify-content: flex-end;
}
.comment-submit-btn {
background: var(--bg-accent-indigo);
color: white;
border: none;
padding: 6px 14px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.comment-submit-btn:hover {
background: var(--bg-accent-indigo-hover);
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
+14
View File
@@ -106,6 +106,7 @@ export interface StickyNoteData {
y: number;
author: string;
content: string;
timestamp?: string;
}
export interface FreehandData {
@@ -243,6 +244,19 @@ class GatewayService {
return response.json();
}
async getDocumentAnnotations(id: string): Promise<any[]> {
try {
const response = await fetch(`${this.baseUrl}/documents/${id}/annotations`);
if (response.status === 501) {
return [];
}
if (!response.ok) throw new Error(`Failed to fetch document annotations: ${response.statusText}`);
return response.json();
} catch (err) {
return [];
}
}
async renderPage(params: RenderParams): Promise<string> {
try {
const query = new URLSearchParams({
+32 -6
View File
@@ -8,7 +8,9 @@ export interface Annotation {
color?: string;
author: string;
content?: string;
timestamp?: string;
paths?: { x: number; y: number }[][];
pageIndex?: number;
}
interface AnnotationLayerProps {
@@ -21,6 +23,7 @@ interface AnnotationLayerProps {
}
export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
pageIndex,
width,
height,
zoom,
@@ -33,7 +36,8 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
style={{ width: `${width}px`, height: `${height}px` }}
>
{annotations
.filter((anno) => anno.type === 'highlight' || anno.type === 'comment')
.filter((anno) => anno.pageIndex === undefined || anno.pageIndex === pageIndex)
.filter((anno) => ['highlight', 'comment', 'strikeout', 'signature'].includes(anno.type))
.map((anno) => {
const scaledBbox = {
x: anno.bbox.x * zoom,
@@ -42,6 +46,11 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
height: anno.bbox.height * zoom,
};
const formattedDate = anno.timestamp ? new Date(anno.timestamp).toLocaleString() : '';
const tooltipText = anno.type === 'comment'
? `${anno.author}${formattedDate ? ` (${formattedDate})` : ''}\n${anno.content || ''}`
: `${anno.author}: ${anno.content || ''}`;
return (
<div
key={anno.id}
@@ -49,23 +58,40 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
e.stopPropagation();
onAnnotationClick?.(anno);
}}
className={`highlight-box type-${anno.type}`}
className={`annotation-box type-${anno.type}`}
style={{
left: `${scaledBbox.x}px`,
top: `${scaledBbox.y}px`,
width: `${scaledBbox.width}px`,
height: `${scaledBbox.height}px`,
}}
title={`${anno.author}: ${anno.content || ''}`}
/>
title={tooltipText}
>
{anno.type === 'comment' && (
<div className="comment-icon" style={{ width: '100%', height: '100%', color: anno.color || '#facc15' }}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-full h-full drop-shadow-md">
<path fillRule="evenodd" d="M4.804 21.644A6.707 6.707 0 006 21.75a6.721 6.721 0 003.583-1.029c.774.182 1.584.279 2.417.279 5.322 0 9.75-3.97 9.75-9 0-5.03-4.428-9-9.75-9s-9.75 3.97-9.75 9c0 2.409 1.025 4.587 2.674 6.192.232.226.277.428.254.543a3.73 3.73 0 01-.814 1.686.75.75 0 00.44 1.223zM8.25 10.875a1.125 1.125 0 100 2.25 1.125 1.125 0 000-2.25zM10.875 12a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0zm4.875-1.125a1.125 1.125 0 100 2.25 1.125 1.125 0 000-2.25z" clipRule="evenodd" />
</svg>
</div>
)}
{anno.type === 'strikeout' && <div className="strikeout-line" />}
{anno.type === 'signature' && (
<div className="signature-badge">
<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">
<path d="M20.42 4.58a5.4 5.4 0 0 0-7.65 0l-.77.78-.77-.78a5.4 5.4 0 0 0-7.65 0C1.46 6.7 1.33 10.28 4 13l8 8 8-8c2.67-2.72 2.54-6.3.42-8.42z"></path>
</svg>
</div>
)}
</div>
);
})}
{/* Ink Annotations */}
<svg
<svg
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none' }}
>
{annotations
.filter((anno) => anno.pageIndex === undefined || anno.pageIndex === pageIndex)
.filter((anno) => anno.type === 'ink' && anno.paths)
.map((anno) => (
<g key={anno.id} stroke={anno.color || '#3b82f6'} strokeWidth={2 * zoom} fill="none" strokeLinecap="round" strokeLinejoin="round">
+86 -2
View File
@@ -75,10 +75,45 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
setCurrentPath([]);
};
const handleLayerClick = (e: React.MouseEvent) => {
if (activeTool === 'comment') {
const coords = getCoordinates(e);
setCommentPopup(coords);
setCommentText('');
}
};
const handleCommentSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (commentPopup && commentText.trim() !== '') {
const newAnno: Annotation = {
id: `anno_${Math.random().toString(36).substring(2, 11)}`,
type: 'comment',
bbox: {
x: commentPopup.x,
y: commentPopup.y,
width: 24, // Standard sticky note icon size
height: 24,
},
author: 'Current User',
content: commentText.trim(),
pageIndex: pageIndex,
timestamp: new Date().toISOString(),
};
onAnnotationAdded?.(newAnno);
}
setCommentPopup(null);
setCommentText('');
};
const [commentPopup, setCommentPopup] = useState<{ x: number, y: number } | null>(null);
const [commentText, setCommentText] = useState('');
return (
<div
className="overlay-layer"
style={{ width: `${width}px`, height: `${height}px`, pointerEvents: activeTool === 'draw' ? 'auto' : 'none' }}
style={{ width: `${width}px`, height: `${height}px`, pointerEvents: ['draw', 'comment'].includes(activeTool) ? 'auto' : 'none' }}
onClick={handleLayerClick}
>
{/* Signature overlay state indicator */}
{activeTool === 'signature' && (
@@ -88,7 +123,56 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
</span>
</div>
)}
{/* Comment Tool Tip */}
{activeTool === 'comment' && !commentPopup && (
<div className="overlay-toast animate-pulse" style={{ pointerEvents: 'none' }}>
Click anywhere to add a Sticky Note
</div>
)}
{/* Comment Input Popup */}
{commentPopup && (
<div
className="comment-popup-container"
style={{
position: 'absolute',
left: `${commentPopup.x * zoom}px`,
top: `${commentPopup.y * zoom}px`,
zIndex: 50,
}}
onClick={(e) => e.stopPropagation()} // Prevent triggering another comment
>
<form onSubmit={handleCommentSubmit} className="comment-form shadow-premium">
<div className="comment-form-header">
<span className="text-xs font-bold text-slate-700">Add Sticky Note</span>
<button type="button" onClick={() => setCommentPopup(null)} className="text-slate-400 hover:text-slate-600">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<textarea
autoFocus
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="Type your comment here..."
className="comment-textarea"
rows={3}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleCommentSubmit(e);
}
}}
/>
<div className="comment-form-footer">
<button type="submit" className="comment-submit-btn">Save Note</button>
</div>
</form>
</div>
)}
{activeTool === 'draw' && (
<>
<div className="overlay-toast animate-pulse" style={{ pointerEvents: 'none' }}>
+3 -2
View File
@@ -230,7 +230,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
verifyPageModel();
}, [visiblePages, documentId]);
const handleTextSelection = (text: string, bbox: Rect) => {
const handleTextSelection = (text: string, bbox: Rect, pageIndex: number) => {
if (activeTool === 'highlight') {
const newAnno: Annotation = {
id: generateUniqueId(),
@@ -243,6 +243,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
},
author: 'Current User',
content: text,
pageIndex: pageIndex,
};
onAnnotationAdded?.(newAnno);
}
@@ -332,7 +333,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
width={page.width}
height={page.height}
zoom={zoom}
onTextSelected={(text, bbox) => handleTextSelection(text, bbox)}
onTextSelected={(text, bbox) => handleTextSelection(text, bbox, page.index)}
/>
{/* signature/ink overlay tool layer */}
+52
View File
@@ -446,3 +446,55 @@ def get_page_model(document_id: str, page_index: int) -> PageModelResponse:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
class AnnotationResponse(BaseModel):
id: str
type: str
x: float
y: float
width: float
height: float
color: str
author: str
content: str
timestamp: str | None = None
pageIndex: int
@router.get("/{document_id}/annotations", response_model=list[AnnotationResponse])
def get_document_annotations(document_id: str) -> list[AnnotationResponse]:
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available.",
)
doc_info = document_store.get_document(document_id)
if not doc_info:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
try:
doc = doc_info["doc_instance"]
all_annots = []
for i in range(doc.page_count):
try:
page = doc.get_page(i)
annots = page.extract_annotations()
for a in annots:
all_annots.append(AnnotationResponse(
id=a.id,
type=a.type,
x=a.x,
y=a.y,
width=a.width,
height=a.height,
color=a.color,
author=a.author,
content=a.content,
timestamp=getattr(a, "timestamp", None),
pageIndex=a.page_index
))
except Exception:
pass
return all_annots
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
+1
View File
@@ -72,6 +72,7 @@ class StickyNoteData(BaseModel):
y: float
author: str
content: str
timestamp: str | None = None
class FreehandPoint(BaseModel):