Merge pull request 'azeem' (#44) from azeem into dev

Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/44
This commit is contained in:
furqan
2026-06-10 14:01:55 +00:00
12 changed files with 352 additions and 13 deletions
+6 -1
View File
@@ -172,7 +172,12 @@ PYBIND11_MODULE(pdfengine, m) {
.def_readonly("content", &pdfengine::PdfPage::AnnotationInfo::content)
.def_readonly("timestamp", &pdfengine::PdfPage::AnnotationInfo::timestamp)
.def_readonly("page_index", &pdfengine::PdfPage::AnnotationInfo::pageIndex)
.def_readonly("paths", &pdfengine::PdfPage::AnnotationInfo::paths);
.def_readonly("paths", &pdfengine::PdfPage::AnnotationInfo::paths)
.def_readonly("field_name", &pdfengine::PdfPage::AnnotationInfo::fieldName)
.def_readonly("field_value", &pdfengine::PdfPage::AnnotationInfo::fieldValue)
.def_readonly("field_type", &pdfengine::PdfPage::AnnotationInfo::fieldType)
.def_readonly("field_flags", &pdfengine::PdfPage::AnnotationInfo::fieldFlags)
.def_readonly("field_options", &pdfengine::PdfPage::AnnotationInfo::fieldOptions);
py::class_<pdfengine::PdfPage, std::shared_ptr<pdfengine::PdfPage>>(m, "PdfPage")
.def_property_readonly("width", &pdfengine::PdfPage::width)
+8 -1
View File
@@ -143,7 +143,7 @@ public:
struct AnnotationInfo {
std::string id;
std::string type; // "highlight", "comment", "ink", "strikeout", "signature"
std::string type; // "highlight", "comment", "ink", "strikeout", "signature", "widget"
double x = 0.0, y = 0.0, width = 0.0, height = 0.0;
std::string color;
std::string author;
@@ -151,6 +151,13 @@ public:
std::string timestamp;
int pageIndex = 0;
std::vector<std::vector<Point2D>> paths;
// Form field specific properties
std::string fieldName;
std::string fieldValue;
std::string fieldType;
int fieldFlags = 0;
std::vector<std::string> fieldOptions;
};
[[nodiscard]] virtual std::expected<std::vector<AnnotationInfo>, EngineError> extractAnnotations() const = 0;
+115 -6
View File
@@ -643,8 +643,9 @@ PdfDocument::loadFromMemory(const std::vector<uint8_t>& data, const std::string&
namespace pdfengine::parser {
PdfiumPage::PdfiumPage(NativePageHandle pageHandle, int pageIndex)
: page_(pageHandle), pageIndex_(pageIndex) {}
PdfiumPage::PdfiumPage(NativeDocHandle docHandle, NativePageHandle pageHandle, int pageIndex)
: doc_(docHandle), page_(pageHandle), pageIndex_(pageIndex) {
}
PdfiumPage::~PdfiumPage() {
#ifdef PDFENGINE_WITH_PDFIUM
@@ -1184,6 +1185,13 @@ std::expected<std::vector<PdfPage::AnnotationInfo>, EngineError> PdfiumPage::ext
if (!page_) {
return std::unexpected(EngineError::Unknown);
}
// Initialize form fill environment locally to extract form fields
FPDF_FORMFILLINFO formInfo;
memset(&formInfo, 0, sizeof(formInfo));
formInfo.version = 1;
FPDF_FORMHANDLE formHandle = FPDFDOC_InitFormFillEnvironment(doc_, &formInfo);
std::vector<PdfPage::AnnotationInfo> result;
int count = FPDFPage_GetAnnotCount(page_);
for (int i = 0; i < count; ++i) {
@@ -1217,7 +1225,62 @@ std::expected<std::vector<PdfPage::AnnotationInfo>, EngineError> PdfiumPage::ext
} else if (subtype == FPDF_ANNOT_STRIKEOUT) {
info.type = "strikeout";
} else if (subtype == FPDF_ANNOT_WIDGET) {
info.type = "signature";
info.type = "widget";
// Basic field properties using PDFium APIs
int fieldType = FPDFAnnot_GetFormFieldType(formHandle, annot);
// Map FPDF_FORMFIELD_* to our types
// 1: PUSHBUTTON, 2: CHECKBOX, 3: RADIOBUTTON, 4: COMBOBOX, 5: LISTBOX, 6: TEXTFIELD, 7: SIGNATURE
if (fieldType == 1 || fieldType == 2 || fieldType == 3) {
info.fieldType = "Btn";
} else if (fieldType == 4 || fieldType == 5) {
info.fieldType = "Ch";
} else if (fieldType == 6) {
info.fieldType = "Tx";
} else if (fieldType == 7) {
info.fieldType = "Sig";
} else {
info.fieldType = "Unknown";
}
info.fieldFlags = FPDFAnnot_GetFormFieldFlags(formHandle, annot);
// Extract Field Name (/T)
unsigned long nameLen = FPDFAnnot_GetFormFieldName(formHandle, annot, nullptr, 0);
if (nameLen > 2) {
std::vector<FPDF_WCHAR> nameBuf(nameLen / 2);
FPDFAnnot_GetFormFieldName(formHandle, annot, nameBuf.data(), nameLen);
std::string text = utf16le_to_utf8(reinterpret_cast<const char16_t*>(nameBuf.data()), nameBuf.size());
while (!text.empty() && text.back() == '\0') text.pop_back();
info.fieldName = text;
}
// Extract Field Value (/V)
unsigned long valLen = FPDFAnnot_GetFormFieldValue(formHandle, annot, nullptr, 0);
if (valLen > 2) {
std::vector<FPDF_WCHAR> valBuf(valLen / 2);
FPDFAnnot_GetFormFieldValue(formHandle, annot, valBuf.data(), valLen);
std::string text = utf16le_to_utf8(reinterpret_cast<const char16_t*>(valBuf.data()), valBuf.size());
while (!text.empty() && text.back() == '\0') text.pop_back();
info.fieldValue = text;
}
// Extract Options for Choice fields (/Opt)
if (info.fieldType == "Ch") {
int optCount = FPDFAnnot_GetOptionCount(formHandle, annot);
for (int o = 0; o < optCount; ++o) {
unsigned long optLen = FPDFAnnot_GetOptionLabel(formHandle, annot, o, nullptr, 0);
if (optLen > 2) {
std::vector<FPDF_WCHAR> optBuf(optLen / 2);
FPDFAnnot_GetOptionLabel(formHandle, annot, o, optBuf.data(), optLen);
std::string optText = utf16le_to_utf8(reinterpret_cast<const char16_t*>(optBuf.data()), optBuf.size());
while (!optText.empty() && optText.back() == '\0') optText.pop_back();
info.fieldOptions.push_back(optText);
}
}
}
} else {
info.type = "unknown";
}
@@ -1310,6 +1373,9 @@ std::expected<std::vector<PdfPage::AnnotationInfo>, EngineError> PdfiumPage::ext
FPDFPage_CloseAnnot(annot);
}
FPDFDOC_ExitFormFillEnvironment(formHandle);
return result;
#else
return std::unexpected(EngineError::Unknown);
@@ -1481,12 +1547,12 @@ std::expected<std::shared_ptr<PdfPage>, EngineError> PdfiumDocument::getPage(int
}
}
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
if (!page) {
FPDF_PAGE pageHandle = FPDF_LoadPage(doc_, pageIndex);
if (!pageHandle) {
return std::unexpected(EngineError::Unknown);
}
auto pageObj = std::make_shared<PdfiumPage>(page, pageIndex);
auto pageObj = std::make_shared<PdfiumPage>(doc_, pageHandle, pageIndex);
{
std::lock_guard<std::mutex> lock(pageCacheMutex_);
pageCache_[pageIndex] = pageObj;
@@ -1694,6 +1760,49 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
}
FPDF_ClosePage(page);
} else if (type == "update_field") {
if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("update_field operation missing 'data' object");
return std::unexpected(EngineError::InvalidFormat);
}
auto data = op["data"];
std::string value;
if (data["value"].is_boolean()) {
value = data["value"].get<bool>() ? "Yes" : "Off";
} else if (data["value"].is_string()) {
value = data["value"].get<std::string>();
} else {
spdlog::error("update_field value must be a string or boolean");
return std::unexpected(EngineError::InvalidFormat);
}
std::string id = op.value("id", "");
int annotIndex = -1;
size_t lastUnderscore = id.find_last_of('_');
if (lastUnderscore != std::string::npos) {
try {
annotIndex = std::stoi(id.substr(lastUnderscore + 1));
} catch (...) {
annotIndex = -1;
}
}
if (annotIndex >= 0) {
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
if (!page) {
spdlog::error("Failed to load page index {} for field update", pageIndex);
return std::unexpected(EngineError::Unknown);
}
FPDF_ANNOTATION annot = FPDFPage_GetAnnot(page, annotIndex);
if (annot) {
auto utf16 = utf8_to_utf16le(value);
FPDFAnnot_SetStringValue(annot, "V", reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
FPDFPage_GenerateContent(page);
FPDFPage_CloseAnnot(annot);
}
FPDF_ClosePage(page);
}
} else if (type == "image_overlay") {
if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("image_overlay operation missing 'data' object");
+2 -1
View File
@@ -28,7 +28,7 @@ using NativeTextHandle = void*;
class PdfiumPage : public PdfPage {
public:
PdfiumPage(NativePageHandle pageHandle, int pageIndex);
PdfiumPage(NativeDocHandle docHandle, NativePageHandle pageHandle, int pageIndex);
~PdfiumPage() override;
PdfiumPage(const PdfiumPage&) = delete;
@@ -53,6 +53,7 @@ public:
Point2D deviceToPage(const DevicePoint& devicePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept override;
private:
NativeDocHandle doc_ = nullptr;
NativePageHandle page_ = nullptr;
mutable NativeTextHandle textPage_ = nullptr;
int pageIndex_ = 0;
+13
View File
@@ -140,6 +140,11 @@ function App() {
pageIndex: a.pageIndex,
// Ink stroke geometry (top-left page points) so the overlay can redraw it interactively.
paths: Array.isArray(a.paths) && a.paths.length > 0 ? a.paths : undefined,
fieldName: a.fieldName,
fieldValue: a.fieldValue,
fieldType: a.fieldType,
fieldFlags: a.fieldFlags,
fieldOptions: a.fieldOptions,
})));
if (preservePageRef.current) preservePageRef.current = false;
else setCurrentPage(0);
@@ -475,6 +480,14 @@ function App() {
hasSignature={!!pendingSignature}
activeStamp={activeStamp?.label ?? null}
annotations={annotations}
onFieldChange={(id, value, i) => {
applyOps([{
id,
type: 'update_field',
pageIndex: i,
data: { value }
} as any]);
}}
searchQuery={searchQuery}
searchResults={searchResults}
searchCurrentMatch={searchCurrentMatch}
+15
View File
@@ -23,6 +23,21 @@ export interface RenderParams {
rotation: number;
}
export interface Annotation {
id: string;
pageIndex: number;
type: string;
x: number;
y: number;
width: number;
height: number;
fieldName?: string;
fieldValue?: string;
fieldType?: string;
fieldFlags?: number;
fieldOptions?: string[];
}
import type { Point } from './coordinateMapping';
export interface SearchRect {
+57 -2
View File
@@ -3,7 +3,7 @@ import type { Rect } from '../lib/coordinateMapping';
export interface Annotation {
id: string;
type: 'highlight' | 'signature' | 'strikeout' | 'comment' | 'ink';
type: 'highlight' | 'signature' | 'strikeout' | 'comment' | 'ink' | 'widget';
bbox: Rect;
color?: string;
opacity?: number;
@@ -13,6 +13,13 @@ export interface Annotation {
timestamp?: string;
paths?: { x: number; y: number }[][];
pageIndex?: number;
// Form fields
fieldName?: string;
fieldValue?: string;
fieldType?: string;
fieldFlags?: number;
fieldOptions?: string[];
}
interface AnnotationLayerProps {
@@ -22,6 +29,7 @@ interface AnnotationLayerProps {
zoom: number;
annotations: Annotation[];
onAnnotationClick?: (annotation: Annotation) => void;
onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
}
export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
@@ -31,6 +39,7 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
zoom,
annotations,
onAnnotationClick,
onFieldChange,
}) => {
return (
<div
@@ -39,7 +48,7 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
>
{annotations
.filter((anno) => anno.pageIndex === undefined || anno.pageIndex === pageIndex)
.filter((anno) => ['highlight', 'comment', 'strikeout', 'signature'].includes(anno.type))
.filter((anno) => ['highlight', 'comment', 'strikeout', 'signature', 'widget'].includes(anno.type))
.map((anno) => {
const scaledBbox = {
x: anno.bbox.x * zoom,
@@ -88,6 +97,52 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
</svg>
</div>
)}
{anno.type === 'widget' && (
<div className="w-full h-full flex items-center justify-center">
{anno.fieldType === 'Tx' && (
<input
type="text"
defaultValue={anno.fieldValue || ''}
onBlur={(e) => {
if (e.target.value !== (anno.fieldValue || '')) {
onFieldChange?.(anno.id, e.target.value, pageIndex);
}
}}
className="w-full h-full bg-blue-50/50 border border-blue-400/50 text-sm px-1 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded-sm"
/>
)}
{anno.fieldType === 'Btn' && (
// Very simple checkbox or radio visualization
<input
type={(anno.fieldFlags ?? 0) & 32768 ? 'radio' : 'checkbox'}
defaultChecked={anno.fieldValue === 'Yes' || anno.fieldValue === 'On'}
onChange={(e) => onFieldChange?.(anno.id, e.target.checked, pageIndex)}
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
/>
)}
{anno.fieldType === 'Ch' && (
<select
defaultValue={anno.fieldValue || ''}
onChange={(e) => onFieldChange?.(anno.id, e.target.value, pageIndex)}
className="w-full h-full bg-blue-50/50 border border-blue-400/50 text-sm px-1 rounded-sm appearance-none"
>
{anno.fieldOptions?.map((opt: string, i: number) => (
<option key={i} value={opt}>{opt}</option>
))}
</select>
)}
{anno.fieldType === 'Sig' && (
<div className="w-full h-full border border-dashed border-gray-400 bg-gray-50/50 flex items-center justify-center text-xs text-gray-500">
Signature Field
</div>
)}
{(!anno.fieldType || anno.fieldType === 'Unknown') && (
<div className="w-full h-full border border-blue-400/50 bg-blue-50/50 flex items-center justify-center text-xs text-blue-500 opacity-50">
Form Field
</div>
)}
</div>
)}
</div>
);
})}
+3
View File
@@ -33,6 +33,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;
onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
}
interface PageLayout {
@@ -69,6 +70,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
onPlaceText,
onPlaceStamp,
onPlaceSignature,
onFieldChange,
}, ref) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const [scrollPosition, setScrollPosition] = useState({ scrollLeft: 0, scrollTop: 0 });
@@ -385,6 +387,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
height={page.height}
zoom={zoom}
annotations={annotations}
onFieldChange={onFieldChange}
/>
{/* Text Selection Dragging Layer */}
+37
View File
@@ -473,6 +473,13 @@ class AnnotationResponse(BaseModel):
# Stroke geometry for ink annotations (top-left page-point space), so the
# frontend can redraw them as an interactive overlay rather than a flat image.
paths: list[list[dict[str, float]]] = []
# Form field properties
fieldName: str | None = None
fieldValue: str | None = None
fieldType: str | None = None
fieldFlags: int | None = None
fieldOptions: list[str] | None = None
@router.get("/{document_id}/annotations", response_model=list[AnnotationResponse])
def get_document_annotations(document_id: str) -> list[AnnotationResponse]:
@@ -507,6 +514,11 @@ def get_document_annotations(document_id: str) -> list[AnnotationResponse]:
timestamp=getattr(a, "timestamp", None),
pageIndex=a.page_index,
paths=[[{"x": p.x, "y": p.y} for p in stroke] for stroke in getattr(a, "paths", [])],
fieldName=getattr(a, "field_name", None),
fieldValue=getattr(a, "field_value", None),
fieldType=getattr(a, "field_type", None),
fieldFlags=getattr(a, "field_flags", None),
fieldOptions=getattr(a, "field_options", None),
))
except Exception:
pass
@@ -533,6 +545,31 @@ def export_document(document_id: str):
filename = d["filename"]
if not filename.endswith(".pdf"):
filename += ".pdf"
# pyrefly: ignore [missing-import]
import sys
import os
roaming_path = os.path.join(os.environ.get("APPDATA", "C:\\Users\\azeem\\AppData\\Roaming"), "Python", "Python312", "site-packages")
if roaming_path not in sys.path:
sys.path.append(roaming_path)
import pypdf
import io
# Parse the raw bytes and force NeedAppearances so the viewer
# actually renders the filled values.
reader = pypdf.PdfReader(io.BytesIO(bytes_data))
writer = pypdf.PdfWriter()
writer.append(reader)
acro_form = writer.root_object.get("/AcroForm")
if acro_form is not None:
acro_form_dict = acro_form.get_object()
acro_form_dict[pypdf.generic.NameObject("/NeedAppearances")] = pypdf.generic.BooleanObject(True)
out_stream = io.BytesIO()
writer.write(out_stream)
bytes_data = out_stream.getvalue()
return Response(
content=bytes_data,
+15 -2
View File
@@ -168,6 +168,17 @@ class PageReorderOperation(BaseModel):
data: PageReorderData
class UpdateFieldData(BaseModel):
value: str | bool
class UpdateFieldOperation(BaseModel):
id: str
type: Literal["update_field"]
pageIndex: int
data: UpdateFieldData
EditOperation = Annotated[
TextOverlayOperation
| RedactionOperation
@@ -178,7 +189,8 @@ EditOperation = Annotated[
| FreehandOperation
| PageRotationOperation
| PageDeletionOperation
| PageReorderOperation,
| PageReorderOperation
| UpdateFieldOperation,
Field(discriminator="type"),
]
@@ -214,7 +226,8 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
import os
import tempfile
from PIL import Image
# pyrefly: ignore [missing-import]
from PIL import Image
# Remove data URI header if present
if "," in img_data_str:
+81
View File
@@ -0,0 +1,81 @@
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import letter
from reportlab.lib.colors import black, blue
def create_form_pdf(filename):
c = canvas.Canvas(filename, pagesize=letter)
c.setFont("Helvetica", 20)
c.drawString(50, 750, "Test Form Fields")
c.setFont("Helvetica", 12)
# Text Field
c.drawString(50, 700, "Name:")
form = c.acroForm
form.textfield(
name='fname',
tooltip='First Name',
x=110, y=695, borderStyle='inset',
borderColor=black, fillColor=None,
width=200, height=20,
textColor=blue, forceBorder=True,
value='Jane Doe'
)
# Checkbox
c.drawString(50, 650, "Subscribe to newsletter:")
form.checkbox(
name='cb1',
tooltip='Subscribe',
x=200, y=645, buttonStyle='check',
borderColor=black, fillColor=None,
textColor=blue, forceBorder=True,
checked=True
)
# Radio Buttons
c.drawString(50, 600, "Gender:")
c.drawString(110, 600, "Male")
c.drawString(170, 600, "Female")
form.radio(
name='radio1',
tooltip='Gender',
value='Male',
selected=False,
x=145, y=595, buttonStyle='cross',
borderStyle='solid', shape='circle',
borderColor=black, fillColor=None,
textColor=blue, forceBorder=True
)
form.radio(
name='radio1',
tooltip='Gender',
value='Female',
selected=True,
x=225, y=595, buttonStyle='cross',
borderStyle='solid', shape='circle',
borderColor=black, fillColor=None,
textColor=blue, forceBorder=True
)
# Choice / Dropdown
c.drawString(50, 550, "Country:")
form.choice(
name='country',
tooltip='Country',
value='USA',
options=[('USA', 'United States'), ('CAN', 'Canada'), ('UK', 'United Kingdom')],
x=110, y=545, width=150, height=20,
borderStyle='solid', borderColor=black,
fillColor=None, textColor=blue, forceBorder=True
)
c.save()
if __name__ == "__main__":
create_form_pdf("test_form.pdf")
print("test_form.pdf created successfully.")
Binary file not shown.