feat(pdf-server): rasterize imported annotations + form/save consistency follow-ups (#593)
* feat(pdf-server): rasterize imported annotations via annotationCanvasMap
Adds an 'imported' annotation type for anything in a loaded PDF that we
either don't model (Ink, Polygon, Caret, FileAttachment, ...) or can't
faithfully re-render (Stamp with an appearance stream, e.g. an image
signature). These now:
- Appear in the annotation panel as '<Subtype> (from PDF)'
- Render in our layer as a positioned div whose body is the per-
annotation canvas pdf.js produced via page.render({annotationCanvasMap})
- if pdf.js didn't divert it (no hasOwnCanvas), the box is transparent
over the main-canvas pixel and just captures clicks
- Are selectable and draggable (resize/rotate disabled - bitmap would
just stretch)
- Are skipped by addAnnotationDicts; getAnnotatedPdfBytes already
filters baseline ids, so save leaves the original in the PDF.
Move/delete are UI-only for now (documented).
Link (2) and Popup (16) are excluded - navigational/auxiliary, not
markup.
importPdfjsAnnotation tests added for unsupported-type and
appearance-stamp paths; computeDiff round-trip for 'imported'.
* fix(pdf-server): isEditing on page.render so stamps always divert to annotationCanvasMap
StampAnnotation.hasOwnCanvas defaults to noRotate; stamps without that
flag composited onto the main canvas, so deleting the 'imported' overlay
left an unclickable pixel. isEditing forces hasOwnCanvas=true for stamps
(via mustBeViewedWhenEditing) so the appearance lands in the per-id
canvas and our DOM element is the only render.
* fix(pdf-server): refresh annotation panel after save rebases baseline
setDirty(false) updated the title and save button but the panel kept
showing pending-change badges because it diffs against
pdfBaselineAnnotations/FormValues and was never re-rendered.
* fix(pdf-server): imported stamp canvases cropped/2x on retina
renderPage applied devicePixelRatio via ctx.scale(dpr,dpr) instead of
page.render's transform parameter. pdf.js sizes annotationCanvasMap
backing buffers as rectW * outputScaleX * viewport.scale, and
outputScaleX is read from transform[0] (defaults 1). So on retina the
per-annotation canvas got a 1x backing while its internal setTransform
(from the SVD of the already-dpr-scaled ctx) was 2x - the appearance
rendered at 2x into a half-sized buffer, showing only the top-left
quarter.
Pass dpr via transform: [dpr,0,0,dpr,0,0] so outputScaleX matches.
Also filter AnnotationLayer.render() to Widget annotations only so it
stops creating empty pointer-events:auto sections for stamps in
#form-layer that could steal clicks from our overlays.
* feat(pdf-server): strip deleted baseline annotations from /Annots on save
buildAnnotatedPdfBytes takes a removedRefs list and walks each page's
/Annots array (backwards) removing matching PDFRef entries.
getAnnotatedPdfBytes computes that list from baseline annotations no
longer in annotationMap, parsing the ref back from our id via
parseAnnotationRef (handles both pdf-<num>-<gen> and pdf-<num>R).
Panel: removed baseline annotations now stay listed as crossed-out cards
with a revert button (mirrors cleared form fields), so the user can see
and undo the pending deletion before save commits it.
* fix(pdf-server): re-render after seeding storage from AcroForm tree
buildFieldNameMap runs AFTER the first renderPage (perf: don't block the
canvas on an O(numPages) scan). When it detects a widget/field-tree
mismatch and pushes the field-tree value into annotationStorage, the
form layer has already rendered the stale widget value. Re-render once
when that happens so the input shows the AcroForm truth.
* fix(pdf-server): reload PDF after successful save instead of rebasing in place
Rebasing baselines while keeping the old pdfDocument drifts: subsequent
renders rasterize annotations that were just stripped from disk, and the
field/widget split pdf-lib's save can create isn't visible until reload.
Reload makes 'viewer == disk' an invariant. localStorage cleared first;
file_changed echo suppressed by lastSavedMtime as before.
This commit is contained in:
parent
3af019144f
commit
61fe39b4db
5 changed files with 505 additions and 31 deletions
|
|
@ -289,10 +289,23 @@ function panelFieldNames(): Set<string> {
|
||||||
return new Set([...formFieldValues.keys(), ...pdfBaselineFormValues.keys()]);
|
return new Set([...formFieldValues.keys(), ...pdfBaselineFormValues.keys()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Baseline annotations the user has deleted. Shown crossed-out in the panel
|
||||||
|
* (mirroring cleared form fields) so they can be reverted, and so save knows
|
||||||
|
* to strip their refs from /Annots. */
|
||||||
|
function removedBaselineAnnotations(): PdfAnnotationDef[] {
|
||||||
|
return deps
|
||||||
|
.state()
|
||||||
|
.pdfBaselineAnnotations.filter((a) => !annotationMap.has(a.id));
|
||||||
|
}
|
||||||
|
|
||||||
/** Total count of annotations + form fields for the sidebar badge.
|
/** Total count of annotations + form fields for the sidebar badge.
|
||||||
* Uses the union so cleared baseline items still contribute. */
|
* Uses the union so cleared baseline items still contribute. */
|
||||||
function sidebarItemCount(): number {
|
function sidebarItemCount(): number {
|
||||||
return annotationMap.size + panelFieldNames().size;
|
return (
|
||||||
|
annotationMap.size +
|
||||||
|
removedBaselineAnnotations().length +
|
||||||
|
panelFieldNames().size
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateAnnotationsBadge(): void {
|
export function updateAnnotationsBadge(): void {
|
||||||
|
|
@ -338,6 +351,8 @@ export function getAnnotationLabel(def: PdfAnnotationDef): string {
|
||||||
return "Line";
|
return "Line";
|
||||||
case "image":
|
case "image":
|
||||||
return "Image";
|
return "Image";
|
||||||
|
case "imported":
|
||||||
|
return `${def.subtype} (from PDF)`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -381,6 +396,8 @@ export function getAnnotationColor(def: PdfAnnotationDef): string {
|
||||||
return "#333";
|
return "#333";
|
||||||
case "image":
|
case "image":
|
||||||
return "#999";
|
return "#999";
|
||||||
|
case "imported":
|
||||||
|
return "#666";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -425,6 +442,14 @@ export function renderAnnotationPanel(): void {
|
||||||
byPage.get(page)!.push(tracked);
|
byPage.get(page)!.push(tracked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Removed baseline annotations: still listed (crossed-out) so they can be
|
||||||
|
// reverted before save strips them from the file.
|
||||||
|
const removedByPage = new Map<number, PdfAnnotationDef[]>();
|
||||||
|
for (const def of removedBaselineAnnotations()) {
|
||||||
|
if (!removedByPage.has(def.page)) removedByPage.set(def.page, []);
|
||||||
|
removedByPage.get(def.page)!.push(def);
|
||||||
|
}
|
||||||
|
|
||||||
// Group form fields by page — iterate the UNION so cleared baseline
|
// Group form fields by page — iterate the UNION so cleared baseline
|
||||||
// fields remain visible (crossed out) with a per-item revert button.
|
// fields remain visible (crossed out) with a per-item revert button.
|
||||||
const fieldsByPage = new Map<number, string[]>();
|
const fieldsByPage = new Map<number, string[]>();
|
||||||
|
|
@ -441,7 +466,11 @@ export function renderAnnotationPanel(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all pages that have annotations or form fields
|
// Collect all pages that have annotations or form fields
|
||||||
const allPages = new Set([...byPage.keys(), ...fieldsByPage.keys()]);
|
const allPages = new Set([
|
||||||
|
...byPage.keys(),
|
||||||
|
...removedByPage.keys(),
|
||||||
|
...fieldsByPage.keys(),
|
||||||
|
]);
|
||||||
const sortedPages = [...allPages].sort((a, b) => a - b);
|
const sortedPages = [...allPages].sort((a, b) => a - b);
|
||||||
|
|
||||||
// Sort annotations within each page by Y position (descending = top-first in PDF coords)
|
// Sort annotations within each page by Y position (descending = top-first in PDF coords)
|
||||||
|
|
@ -466,8 +495,9 @@ export function renderAnnotationPanel(): void {
|
||||||
const sectionKey = `page-${pageNum}`;
|
const sectionKey = `page-${pageNum}`;
|
||||||
const isOpen = panelState.openAccordionSection === sectionKey;
|
const isOpen = panelState.openAccordionSection === sectionKey;
|
||||||
const annotations = byPage.get(pageNum) ?? [];
|
const annotations = byPage.get(pageNum) ?? [];
|
||||||
|
const removed = removedByPage.get(pageNum) ?? [];
|
||||||
const fields = fieldsByPage.get(pageNum) ?? [];
|
const fields = fieldsByPage.get(pageNum) ?? [];
|
||||||
const itemCount = annotations.length + fields.length;
|
const itemCount = annotations.length + removed.length + fields.length;
|
||||||
|
|
||||||
appendAccordionSection(
|
appendAccordionSection(
|
||||||
`Page ${pageNum} (${itemCount})`,
|
`Page ${pageNum} (${itemCount})`,
|
||||||
|
|
@ -483,6 +513,10 @@ export function renderAnnotationPanel(): void {
|
||||||
for (const tracked of annotations) {
|
for (const tracked of annotations) {
|
||||||
body.appendChild(createAnnotationCard(tracked));
|
body.appendChild(createAnnotationCard(tracked));
|
||||||
}
|
}
|
||||||
|
// Then removed baseline annotations (crossed-out, revertable)
|
||||||
|
for (const def of removed) {
|
||||||
|
body.appendChild(createRemovedAnnotationCard(def));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -648,6 +682,55 @@ function createAnnotationCard(tracked: TrackedAnnotation): HTMLElement {
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card for a baseline annotation the user deleted: crossed-out, no select/
|
||||||
|
* navigate (it has no DOM on the page anymore), revert button puts it back
|
||||||
|
* into `annotationMap` so it renders again and save leaves it in the file.
|
||||||
|
*/
|
||||||
|
function createRemovedAnnotationCard(def: PdfAnnotationDef): HTMLElement {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "annotation-card annotation-card-cleared";
|
||||||
|
card.dataset.annotationId = def.id;
|
||||||
|
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "annotation-card-row";
|
||||||
|
|
||||||
|
const swatch = document.createElement("div");
|
||||||
|
swatch.className = "annotation-card-swatch annotation-card-swatch-cleared";
|
||||||
|
swatch.innerHTML = `<svg width="10" height="10" viewBox="0 0 10 10" stroke="${getAnnotationColor(def)}" stroke-width="1.5" stroke-linecap="round"><path d="M2 2l6 6M8 2L2 8"/></svg>`;
|
||||||
|
row.appendChild(swatch);
|
||||||
|
|
||||||
|
const typeLabel = document.createElement("span");
|
||||||
|
typeLabel.className = "annotation-card-type";
|
||||||
|
typeLabel.textContent = getAnnotationLabel(def);
|
||||||
|
row.appendChild(typeLabel);
|
||||||
|
|
||||||
|
const preview = getAnnotationPreview(def);
|
||||||
|
if (preview) {
|
||||||
|
const previewEl = document.createElement("span");
|
||||||
|
previewEl.className = "annotation-card-preview";
|
||||||
|
previewEl.textContent = preview;
|
||||||
|
row.appendChild(previewEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revertBtn = document.createElement("button");
|
||||||
|
revertBtn.className = "annotation-card-delete";
|
||||||
|
revertBtn.title = "Restore annotation from file";
|
||||||
|
revertBtn.innerHTML = REVERT_SVG;
|
||||||
|
revertBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
annotationMap.set(def.id, { def: { ...def }, elements: [] });
|
||||||
|
updateAnnotationsBadge();
|
||||||
|
renderAnnotationPanel();
|
||||||
|
deps.renderPage();
|
||||||
|
deps.persistAnnotations();
|
||||||
|
});
|
||||||
|
row.appendChild(revertBtn);
|
||||||
|
|
||||||
|
card.appendChild(row);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
/** Revert one field to its PDF-stored baseline value. */
|
/** Revert one field to its PDF-stored baseline value. */
|
||||||
function revertFieldToBaseline(name: string): void {
|
function revertFieldToBaseline(name: string): void {
|
||||||
const base = pdfBaselineFormValues.get(name);
|
const base = pdfBaselineFormValues.get(name);
|
||||||
|
|
|
||||||
|
|
@ -590,6 +590,21 @@ body {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Annotation imported verbatim from the PDF (Stamp/Ink/etc. via
|
||||||
|
* annotationCanvasMap). The body is the rasterized appearance canvas;
|
||||||
|
* if pdf.js didn't divert it (no hasOwnCanvas), the box stays transparent
|
||||||
|
* over the main-canvas pixel and just captures clicks. */
|
||||||
|
.annotation-imported {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.annotation-imported:hover {
|
||||||
|
outline: 1px dashed var(--accent, #2563eb);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Selection visuals */
|
/* Selection visuals */
|
||||||
.annotation-selected {
|
.annotation-selected {
|
||||||
outline: 2px solid var(--accent, #2563eb);
|
outline: 2px solid var(--accent, #2563eb);
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
type LineAnnotation,
|
type LineAnnotation,
|
||||||
type StampAnnotation,
|
type StampAnnotation,
|
||||||
type ImageAnnotation,
|
type ImageAnnotation,
|
||||||
|
type ImportedAnnotation,
|
||||||
type NoteAnnotation,
|
type NoteAnnotation,
|
||||||
type FreetextAnnotation,
|
type FreetextAnnotation,
|
||||||
cssColorToRgb,
|
cssColorToRgb,
|
||||||
|
|
@ -34,6 +35,7 @@ import {
|
||||||
computeDiff,
|
computeDiff,
|
||||||
isDiffEmpty,
|
isDiffEmpty,
|
||||||
buildAnnotatedPdfBytes,
|
buildAnnotatedPdfBytes,
|
||||||
|
parseAnnotationRef,
|
||||||
importPdfjsAnnotation,
|
importPdfjsAnnotation,
|
||||||
uint8ArrayToBase64,
|
uint8ArrayToBase64,
|
||||||
convertFromModelCoords,
|
convertFromModelCoords,
|
||||||
|
|
@ -1383,6 +1385,10 @@ const DRAGGABLE_TYPES = new Set<string>([
|
||||||
"stamp",
|
"stamp",
|
||||||
"note",
|
"note",
|
||||||
"image",
|
"image",
|
||||||
|
// "imported" is draggable in the UI but the move does NOT persist to the
|
||||||
|
// PDF on save (addAnnotationDicts skips it). Resize/rotate stay disabled
|
||||||
|
// — the appearance bitmap would just stretch.
|
||||||
|
"imported",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function setupAnnotationInteraction(
|
function setupAnnotationInteraction(
|
||||||
|
|
@ -1890,6 +1896,25 @@ function paintAnnotationsOnCanvas(
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "imported": {
|
||||||
|
const s = pdfRectToScreen(
|
||||||
|
{ x: def.x, y: def.y, width: def.width, height: def.height },
|
||||||
|
viewport,
|
||||||
|
);
|
||||||
|
const bmp = annotationCanvasMap.get(def.pdfjsId);
|
||||||
|
ctx.save();
|
||||||
|
if (bmp) {
|
||||||
|
ctx.drawImage(bmp, s.left, s.top, s.width, s.height);
|
||||||
|
} else {
|
||||||
|
ctx.strokeStyle = "#666";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([3, 3]);
|
||||||
|
ctx.strokeRect(s.left, s.top, s.width, s.height);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1987,6 +2012,8 @@ function renderAnnotation(
|
||||||
return [renderLineAnnotation(def, viewport)];
|
return [renderLineAnnotation(def, viewport)];
|
||||||
case "image":
|
case "image":
|
||||||
return [renderImageAnnotation(def, viewport)];
|
return [renderImageAnnotation(def, viewport)];
|
||||||
|
case "imported":
|
||||||
|
return [renderImportedAnnotation(def, viewport)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2175,6 +2202,45 @@ function renderImageAnnotation(
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-annotation appearance bitmaps from page.render(). Keyed by pdf.js
|
||||||
|
* annotation id (e.g. "118R"). Populated for the current page only —
|
||||||
|
* cleared at the start of each renderPage().
|
||||||
|
*/
|
||||||
|
const annotationCanvasMap = new Map<string, HTMLCanvasElement>();
|
||||||
|
|
||||||
|
function renderImportedAnnotation(
|
||||||
|
def: ImportedAnnotation,
|
||||||
|
viewport: { width: number; height: number; scale: number },
|
||||||
|
): HTMLElement {
|
||||||
|
const screen = pdfRectToScreen(
|
||||||
|
{ x: def.x, y: def.y, width: def.width, height: def.height },
|
||||||
|
viewport,
|
||||||
|
);
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "annotation-imported";
|
||||||
|
el.style.left = `${screen.left}px`;
|
||||||
|
el.style.top = `${screen.top}px`;
|
||||||
|
el.style.width = `${screen.width}px`;
|
||||||
|
el.style.height = `${screen.height}px`;
|
||||||
|
el.title = `${def.subtype} (from PDF)`;
|
||||||
|
|
||||||
|
// page.render() may or may not have produced a separate canvas for this
|
||||||
|
// annotation (hasOwnCanvas depends on the PDF's flags). When it did, use
|
||||||
|
// it as a pixel-faithful body; when it didn't, the appearance is on the
|
||||||
|
// main canvas already, so leave the box transparent — it still captures
|
||||||
|
// clicks for select/delete.
|
||||||
|
const canvas = annotationCanvasMap.get(def.pdfjsId);
|
||||||
|
if (canvas) {
|
||||||
|
canvas.style.width = "100%";
|
||||||
|
canvas.style.height = "100%";
|
||||||
|
canvas.style.display = "block";
|
||||||
|
canvas.style.pointerEvents = "none";
|
||||||
|
el.appendChild(canvas);
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Annotation CRUD
|
// Annotation CRUD
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -2761,7 +2827,8 @@ function normaliseFieldValue(
|
||||||
*/
|
*/
|
||||||
async function buildFieldNameMap(
|
async function buildFieldNameMap(
|
||||||
doc: pdfjsLib.PDFDocumentProxy,
|
doc: pdfjsLib.PDFDocumentProxy,
|
||||||
): Promise<void> {
|
): Promise<boolean> {
|
||||||
|
let pushedToStorage = false;
|
||||||
fieldNameToIds.clear();
|
fieldNameToIds.clear();
|
||||||
radioButtonValues.clear();
|
radioButtonValues.clear();
|
||||||
fieldNameToPage.clear();
|
fieldNameToPage.clear();
|
||||||
|
|
@ -2857,12 +2924,16 @@ async function buildFieldNameMap(
|
||||||
if (!widgetIds) continue; // no widget → not rendered anyway
|
if (!widgetIds) continue; // no widget → not rendered anyway
|
||||||
|
|
||||||
// Type comes from getFieldObjects (widget annot data doesn't have it).
|
// Type comes from getFieldObjects (widget annot data doesn't have it).
|
||||||
// Value comes from the widget annotation (fall back to field-dict if
|
// Value: prefer the AcroForm field-tree value over the widget's
|
||||||
// the widget didn't expose one).
|
// fieldValue. pdf-lib's save() can leave a page widget pointing at a
|
||||||
|
// stale /V while the field tree has the new one (seen with comb text
|
||||||
|
// fields), and getAnnotations() reads the widget. If the two disagree
|
||||||
|
// we push the field-tree value into annotationStorage so the rendered
|
||||||
|
// input matches what's actually in /AcroForm.
|
||||||
const type = fieldArr.find((f) => f.type)?.type;
|
const type = fieldArr.find((f) => f.type)?.type;
|
||||||
const raw = widgetFieldValues.has(name)
|
const fieldTreeRaw = fieldArr.find((f) => f.value != null)?.value;
|
||||||
? widgetFieldValues.get(name)
|
const widgetRaw = widgetFieldValues.get(name);
|
||||||
: fieldArr.find((f) => f.value != null)?.value;
|
const raw = fieldTreeRaw ?? widgetRaw;
|
||||||
const v = normaliseFieldValue(type, raw);
|
const v = normaliseFieldValue(type, raw);
|
||||||
if (v !== null) {
|
if (v !== null) {
|
||||||
pdfBaselineFormValues.set(name, v);
|
pdfBaselineFormValues.set(name, v);
|
||||||
|
|
@ -2870,6 +2941,13 @@ async function buildFieldNameMap(
|
||||||
// restored localStorage diff (applied in restoreAnnotations) will
|
// restored localStorage diff (applied in restoreAnnotations) will
|
||||||
// overwrite specific fields the user changed.
|
// overwrite specific fields the user changed.
|
||||||
if (!formFieldValues.has(name)) formFieldValues.set(name, v);
|
if (!formFieldValues.has(name)) formFieldValues.set(name, v);
|
||||||
|
// Widget out of sync with field tree → force storage so
|
||||||
|
// AnnotationLayer renders the field-tree value, not the stale
|
||||||
|
// widget. (syncFormValuesToStorage skips baseline==current.)
|
||||||
|
if (fieldTreeRaw != null && fieldTreeRaw !== widgetRaw) {
|
||||||
|
setFieldInStorage(name, v);
|
||||||
|
pushedToStorage = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip parent entries with no concrete id (radio groups: the /T tree
|
// Skip parent entries with no concrete id (radio groups: the /T tree
|
||||||
|
|
@ -2884,6 +2962,7 @@ async function buildFieldNameMap(
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(`Built field name map: ${fieldNameToIds.size} fields`);
|
log.info(`Built field name map: ${fieldNameToIds.size} fields`);
|
||||||
|
return pushedToStorage;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -2971,6 +3050,14 @@ async function getAnnotatedPdfBytes(): Promise<Uint8Array> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Baseline annotations the user deleted: strip their refs from /Annots so
|
||||||
|
// they don't reappear on reload. Ids without a recoverable ref (page-index
|
||||||
|
// fallback) can't be removed by-ref and are skipped.
|
||||||
|
const removedRefs = pdfBaselineAnnotations
|
||||||
|
.filter((a) => !annotationMap.has(a.id))
|
||||||
|
.map((a) => parseAnnotationRef(a.id))
|
||||||
|
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||||
|
|
||||||
// Only write fields that actually changed vs. what's already in the PDF.
|
// Only write fields that actually changed vs. what's already in the PDF.
|
||||||
// Unchanged fields are no-ops at best, and at worst trip pdf-lib edge
|
// Unchanged fields are no-ops at best, and at worst trip pdf-lib edge
|
||||||
// cases (max-length text, missing /Yes appearance, …) on fields the user
|
// cases (max-length text, missing /Yes appearance, …) on fields the user
|
||||||
|
|
@ -2995,6 +3082,7 @@ async function getAnnotatedPdfBytes(): Promise<Uint8Array> {
|
||||||
fullBytes as Uint8Array,
|
fullBytes as Uint8Array,
|
||||||
annotations,
|
annotations,
|
||||||
formFieldsOut,
|
formFieldsOut,
|
||||||
|
removedRefs,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3036,15 +3124,6 @@ async function savePdf(): Promise<void> {
|
||||||
const sc = result.structuredContent as { mtimeMs?: number } | undefined;
|
const sc = result.structuredContent as { mtimeMs?: number } | undefined;
|
||||||
lastSavedMtime = sc?.mtimeMs ?? null;
|
lastSavedMtime = sc?.mtimeMs ?? null;
|
||||||
|
|
||||||
// Rebase: the file on disk now contains our annotations + form values.
|
|
||||||
// Update the baseline so future diffs are relative to what was saved.
|
|
||||||
pdfBaselineAnnotations = [...annotationMap.values()].map((t) => ({
|
|
||||||
...t.def,
|
|
||||||
}));
|
|
||||||
pdfBaselineFormValues.clear();
|
|
||||||
for (const [k, v] of formFieldValues) pdfBaselineFormValues.set(k, v);
|
|
||||||
|
|
||||||
setDirty(false); // → updateSaveBtn() disables button
|
|
||||||
const key = annotationStorageKey();
|
const key = annotationStorageKey();
|
||||||
if (key) {
|
if (key) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -3053,6 +3132,13 @@ async function savePdf(): Promise<void> {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Reload from the bytes we just wrote. The previous approach (rebase
|
||||||
|
// baselines but keep the old pdfDocument) drifts: subsequent renders
|
||||||
|
// still rasterize stripped annotations from the old bytes, and the
|
||||||
|
// field/widget split that pdf-lib's save can create isn't reflected
|
||||||
|
// until reload anyway. Reload makes "what you see = what's on disk"
|
||||||
|
// an invariant. (file_changed echo is suppressed by lastSavedMtime.)
|
||||||
|
await reloadPdf();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error("Save failed:", err);
|
log.error("Save failed:", err);
|
||||||
|
|
@ -3156,8 +3242,15 @@ async function renderPage() {
|
||||||
// resize so the size handoff is atomic.
|
// resize so the size handoff is atomic.
|
||||||
pageWrapperEl.style.transform = "";
|
pageWrapperEl.style.transform = "";
|
||||||
|
|
||||||
// Scale context for retina
|
// Retina: pass dpr via page.render's `transform` (NOT ctx.scale).
|
||||||
ctx.scale(dpr, dpr);
|
// pdf.js sizes per-annotation canvases as
|
||||||
|
// width = rectW * outputScaleX * viewport.scale
|
||||||
|
// and outputScaleX is read from transform[0] (defaults to 1). A bare
|
||||||
|
// ctx.scale(dpr,dpr) leaves outputScaleX at 1, so the
|
||||||
|
// annotationCanvasMap canvases get a half-sized backing store on
|
||||||
|
// retina while their internal setTransform IS dpr-aware → the
|
||||||
|
// appearance renders 2× too big into a 1× buffer → cropped/shifted.
|
||||||
|
const dprTransform = dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined;
|
||||||
|
|
||||||
// Clear and setup text layer
|
// Clear and setup text layer
|
||||||
textLayerEl.innerHTML = "";
|
textLayerEl.innerHTML = "";
|
||||||
|
|
@ -3166,11 +3259,29 @@ async function renderPage() {
|
||||||
// Set --scale-factor so CSS font-size/transform rules work correctly.
|
// Set --scale-factor so CSS font-size/transform rules work correctly.
|
||||||
textLayerEl.style.setProperty("--scale-factor", `${scale}`);
|
textLayerEl.style.setProperty("--scale-factor", `${scale}`);
|
||||||
|
|
||||||
// Render canvas - track the task so we can cancel it
|
// Render canvas - track the task so we can cancel it.
|
||||||
|
//
|
||||||
|
// annotationCanvasMap: pdf.js diverts annotations whose appearance needs
|
||||||
|
// its own bitmap (Stamp/Ink/FreeText/etc. with hasOwnCanvas) into
|
||||||
|
// per-id canvases instead of compositing onto the main canvas.
|
||||||
|
// renderImportedAnnotation() pulls from this map so those annotations
|
||||||
|
// become movable DOM elements with pixel-faithful visuals — instead of
|
||||||
|
// unselectable canvas pixels (the old "ghost annotation" problem) or
|
||||||
|
// our lossy text-label re-render.
|
||||||
|
annotationCanvasMap.clear();
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const renderTask = (page.render as any)({
|
const renderTask = (page.render as any)({
|
||||||
canvasContext: ctx,
|
canvasContext: ctx,
|
||||||
viewport,
|
viewport,
|
||||||
|
transform: dprTransform,
|
||||||
|
annotationCanvasMap,
|
||||||
|
// isEditing forces hasOwnCanvas=true for stamps regardless of /F
|
||||||
|
// NoRotate (StampAnnotation.mustBeViewedWhenEditing in pdf.worker).
|
||||||
|
// Without this, stamps without NoRotate composite onto the main canvas
|
||||||
|
// and deleting the "imported" overlay leaves an unclickable pixel
|
||||||
|
// behind. Other markup types still gate on noRotate; for those the
|
||||||
|
// overlay stays a transparent click-box (delete is UI-only until save).
|
||||||
|
isEditing: true,
|
||||||
});
|
});
|
||||||
currentRenderTask = renderTask;
|
currentRenderTask = renderTask;
|
||||||
|
|
||||||
|
|
@ -3251,8 +3362,15 @@ async function renderPage() {
|
||||||
commentManager: null,
|
commentManager: null,
|
||||||
} as any);
|
} as any);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
// Only feed Widgets (form fields) here. Markup annotations are
|
||||||
|
// owned by #annotation-layer; letting AnnotationLayer create
|
||||||
|
// <section> elements for them in #form-layer adds invisible
|
||||||
|
// pointer-events:auto boxes that steal clicks from our overlays.
|
||||||
|
const widgetAnns = annotations.filter(
|
||||||
|
(a: { subtype?: string }) => a.subtype === "Widget",
|
||||||
|
);
|
||||||
await annotationLayer.render({
|
await annotationLayer.render({
|
||||||
annotations,
|
annotations: widgetAnns,
|
||||||
div: formLayerEl,
|
div: formLayerEl,
|
||||||
page,
|
page,
|
||||||
viewport,
|
viewport,
|
||||||
|
|
@ -4313,8 +4431,9 @@ async function reloadPdf(): Promise<void> {
|
||||||
await renderPage();
|
await renderPage();
|
||||||
|
|
||||||
await loadBaselineAnnotations(document);
|
await loadBaselineAnnotations(document);
|
||||||
await buildFieldNameMap(document);
|
const seeded = await buildFieldNameMap(document);
|
||||||
syncFormValuesToStorage();
|
syncFormValuesToStorage();
|
||||||
|
if (seeded) await renderPage();
|
||||||
updateAnnotationsBadge();
|
updateAnnotationsBadge();
|
||||||
renderAnnotationPanel();
|
renderAnnotationPanel();
|
||||||
|
|
||||||
|
|
@ -4539,9 +4658,16 @@ app.ontoolresult = async (result: CallToolResult) => {
|
||||||
restoreAnnotations();
|
restoreAnnotations();
|
||||||
|
|
||||||
// Build field name → annotation ID mapping for form filling
|
// Build field name → annotation ID mapping for form filling
|
||||||
await buildFieldNameMap(document);
|
const seeded = await buildFieldNameMap(document);
|
||||||
// Pre-populate annotationStorage from restored formFieldValues
|
// Pre-populate annotationStorage from restored formFieldValues
|
||||||
syncFormValuesToStorage();
|
syncFormValuesToStorage();
|
||||||
|
// buildFieldNameMap may have pushed AcroForm-tree values into storage
|
||||||
|
// (when the page widget's /V is stale vs the field dict — pdf-lib's save
|
||||||
|
// can leave them split). The first renderPage above ran BEFORE that, so
|
||||||
|
// the form layer shows the stale widget value. Re-render so it picks up
|
||||||
|
// storage. Only when something was actually seeded — most PDFs don't hit
|
||||||
|
// this and the extra render would be pure waste.
|
||||||
|
if (seeded) await renderPage();
|
||||||
|
|
||||||
updateAnnotationsBadge();
|
updateAnnotationsBadge();
|
||||||
// Save button visibility driven by setDirty()/updateSaveBtn();
|
// Save button visibility driven by setDirty()/updateSaveBtn();
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
defaultColor,
|
defaultColor,
|
||||||
importPdfjsAnnotation,
|
importPdfjsAnnotation,
|
||||||
buildAnnotatedPdfBytes,
|
buildAnnotatedPdfBytes,
|
||||||
|
parseAnnotationRef,
|
||||||
base64ToUint8Array,
|
base64ToUint8Array,
|
||||||
uint8ArrayToBase64,
|
uint8ArrayToBase64,
|
||||||
convertFromModelCoords,
|
convertFromModelCoords,
|
||||||
|
|
@ -549,6 +550,76 @@ describe("importPdfjsAnnotation", () => {
|
||||||
expect(result!.page).toBe(2);
|
expect(result!.page).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("imports an unsupported subtype as 'imported' (placement only)", () => {
|
||||||
|
// annotationType 15 = Ink, not in PDFJS_TYPE_MAP. We keep it as a
|
||||||
|
// placement-only "imported" record so it's listed in the panel and
|
||||||
|
// rendered from annotationCanvasMap instead of being dropped.
|
||||||
|
const ann = {
|
||||||
|
annotationType: 15,
|
||||||
|
subtype: "Ink",
|
||||||
|
id: "200R",
|
||||||
|
rect: [100, 200, 180, 260],
|
||||||
|
};
|
||||||
|
const result = importPdfjsAnnotation(ann, 3, 0);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.type).toBe("imported");
|
||||||
|
expect(result!.page).toBe(3);
|
||||||
|
expect((result as any).pdfjsId).toBe("200R");
|
||||||
|
expect((result as any).subtype).toBe("Ink");
|
||||||
|
expect((result as any).width).toBeCloseTo(80);
|
||||||
|
expect((result as any).height).toBeCloseTo(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("imports an appearance-stream stamp as 'imported' (not text-label)", () => {
|
||||||
|
// A Stamp with hasAppearance carries a custom visual (e.g. an image
|
||||||
|
// signature) that our text-label StampAnnotation can't reproduce.
|
||||||
|
const ann = {
|
||||||
|
annotationType: 13,
|
||||||
|
subtype: "Stamp",
|
||||||
|
id: "118R",
|
||||||
|
rect: [420, 760, 514, 792],
|
||||||
|
hasAppearance: true,
|
||||||
|
contentsObj: { str: "DRAFT" },
|
||||||
|
};
|
||||||
|
const result = importPdfjsAnnotation(ann, 1, 0);
|
||||||
|
expect(result!.type).toBe("imported");
|
||||||
|
expect((result as any).pdfjsId).toBe("118R");
|
||||||
|
expect((result as any).subtype).toBe("Stamp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computeDiff: 'imported' present in both baseline and current → no diff", () => {
|
||||||
|
const imp: PdfAnnotationDef = {
|
||||||
|
type: "imported",
|
||||||
|
id: "pdf-118R",
|
||||||
|
page: 1,
|
||||||
|
x: 420,
|
||||||
|
y: 760,
|
||||||
|
width: 94,
|
||||||
|
height: 32,
|
||||||
|
pdfjsId: "118R",
|
||||||
|
subtype: "Stamp",
|
||||||
|
};
|
||||||
|
const diff = computeDiff([imp], [imp], new Map());
|
||||||
|
expect(diff.added).toHaveLength(0);
|
||||||
|
expect(diff.removed).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computeDiff: deleting an 'imported' annotation lists it in removed", () => {
|
||||||
|
const imp: PdfAnnotationDef = {
|
||||||
|
type: "imported",
|
||||||
|
id: "pdf-118R",
|
||||||
|
page: 1,
|
||||||
|
x: 420,
|
||||||
|
y: 760,
|
||||||
|
width: 94,
|
||||||
|
height: 32,
|
||||||
|
pdfjsId: "118R",
|
||||||
|
subtype: "Stamp",
|
||||||
|
};
|
||||||
|
const diff = computeDiff([imp], [], new Map());
|
||||||
|
expect(diff.removed).toEqual(["pdf-118R"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("imports a note (Text) annotation", () => {
|
it("imports a note (Text) annotation", () => {
|
||||||
const ann = {
|
const ann = {
|
||||||
annotationType: 1,
|
annotationType: 1,
|
||||||
|
|
@ -718,6 +789,29 @@ describe("base64 helpers", () => {
|
||||||
// PDF Annotation Dict Creation (integration test with pdf-lib)
|
// PDF Annotation Dict Creation (integration test with pdf-lib)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("parseAnnotationRef", () => {
|
||||||
|
it("parses pdf-<num>-<gen> ids", () => {
|
||||||
|
expect(parseAnnotationRef("pdf-118-0")).toEqual({
|
||||||
|
objectNumber: 118,
|
||||||
|
generationNumber: 0,
|
||||||
|
});
|
||||||
|
expect(parseAnnotationRef("pdf-5-2")).toEqual({
|
||||||
|
objectNumber: 5,
|
||||||
|
generationNumber: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("parses pdf-<num>R ids (pdf.js string id, gen=0)", () => {
|
||||||
|
expect(parseAnnotationRef("pdf-118R")).toEqual({
|
||||||
|
objectNumber: 118,
|
||||||
|
generationNumber: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("returns null for page-index fallback ids", () => {
|
||||||
|
expect(parseAnnotationRef("pdf-1-idx-3")).toBeNull();
|
||||||
|
expect(parseAnnotationRef("user-abc")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("buildAnnotatedPdfBytes", () => {
|
describe("buildAnnotatedPdfBytes", () => {
|
||||||
let blankPdfBytes: Uint8Array;
|
let blankPdfBytes: Uint8Array;
|
||||||
|
|
||||||
|
|
@ -738,6 +832,53 @@ describe("buildAnnotatedPdfBytes", () => {
|
||||||
expect(header).toBe("%PDF-");
|
expect(header).toBe("%PDF-");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("strips removedRefs entries from each page's /Annots array", async () => {
|
||||||
|
// Seed: add two highlights, save, capture their object refs.
|
||||||
|
const seeded = await buildAnnotatedPdfBytes(
|
||||||
|
blankPdfBytes,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "highlight",
|
||||||
|
id: "h1",
|
||||||
|
page: 1,
|
||||||
|
rects: [{ x: 72, y: 700, width: 100, height: 12 }],
|
||||||
|
color: "#ffff00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "highlight",
|
||||||
|
id: "h2",
|
||||||
|
page: 1,
|
||||||
|
rects: [{ x: 72, y: 680, width: 100, height: 12 }],
|
||||||
|
color: "#ffff00",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
|
const seededDoc = await PDFDocument.load(seeded);
|
||||||
|
const annots = seededDoc.getPage(0).node.Annots()!;
|
||||||
|
expect(annots.size()).toBe(2);
|
||||||
|
const ref0 = annots.get(0) as unknown as {
|
||||||
|
objectNumber: number;
|
||||||
|
generationNumber: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Now remove the first one by ref.
|
||||||
|
const stripped = await buildAnnotatedPdfBytes(seeded, [], new Map(), [
|
||||||
|
{ objectNumber: ref0.objectNumber, generationNumber: ref0.generationNumber },
|
||||||
|
]);
|
||||||
|
const strippedDoc = await PDFDocument.load(stripped);
|
||||||
|
const remaining = strippedDoc.getPage(0).node.Annots();
|
||||||
|
expect(remaining?.size() ?? 0).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removedRefs ignores refs not present in /Annots", async () => {
|
||||||
|
const out = await buildAnnotatedPdfBytes(blankPdfBytes, [], new Map(), [
|
||||||
|
{ objectNumber: 9999, generationNumber: 0 },
|
||||||
|
]);
|
||||||
|
const doc = await PDFDocument.load(out);
|
||||||
|
expect(doc.getPage(0).node.Annots()?.size() ?? 0).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("adds highlight annotation to PDF", async () => {
|
it("adds highlight annotation to PDF", async () => {
|
||||||
const annotations: PdfAnnotationDef[] = [
|
const annotations: PdfAnnotationDef[] = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,32 @@ export interface ImageAnnotation extends AnnotationBase {
|
||||||
aspect?: "preserve" | "ignore";
|
aspect?: "preserve" | "ignore";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An annotation that already exists in the loaded PDF and that we render
|
||||||
|
* verbatim from its appearance stream (via pdf.js's annotationCanvasMap)
|
||||||
|
* rather than re-modeling it with one of our shape types.
|
||||||
|
*
|
||||||
|
* Covers two cases:
|
||||||
|
* - subtypes we don't model (Ink, Polygon, Caret, FileAttachment, …)
|
||||||
|
* - subtypes we *could* model but whose appearance carries information
|
||||||
|
* our model would drop (e.g. Stamp with an image signature)
|
||||||
|
*
|
||||||
|
* The rasterized canvas is supplied at render time by mcp-app.ts; this
|
||||||
|
* struct only carries placement and identity. Coords are PDF user-space
|
||||||
|
* (origin bottom-left), matching the other rect-shaped types.
|
||||||
|
*/
|
||||||
|
export interface ImportedAnnotation extends AnnotationBase {
|
||||||
|
type: "imported";
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
/** pdf.js getAnnotations() id (e.g. "118R") — key into annotationCanvasMap. */
|
||||||
|
pdfjsId: string;
|
||||||
|
/** Original PDF /Subtype (e.g. "Stamp", "Ink") for the panel label. */
|
||||||
|
subtype: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type PdfAnnotationDef =
|
export type PdfAnnotationDef =
|
||||||
| HighlightAnnotation
|
| HighlightAnnotation
|
||||||
| UnderlineAnnotation
|
| UnderlineAnnotation
|
||||||
|
|
@ -140,7 +166,8 @@ export type PdfAnnotationDef =
|
||||||
| LineAnnotation
|
| LineAnnotation
|
||||||
| FreetextAnnotation
|
| FreetextAnnotation
|
||||||
| StampAnnotation
|
| StampAnnotation
|
||||||
| ImageAnnotation;
|
| ImageAnnotation
|
||||||
|
| ImportedAnnotation;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Coordinate Conversion (model ↔ internal PDF coords)
|
// Coordinate Conversion (model ↔ internal PDF coords)
|
||||||
|
|
@ -174,6 +201,7 @@ export function convertFromModelCoords(
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
case "circle":
|
case "circle":
|
||||||
case "image":
|
case "image":
|
||||||
|
case "imported":
|
||||||
return { ...def, y: pageHeight - def.y - def.height };
|
return { ...def, y: pageHeight - def.y - def.height };
|
||||||
case "line":
|
case "line":
|
||||||
return {
|
return {
|
||||||
|
|
@ -374,6 +402,7 @@ export function defaultColor(type: PdfAnnotationDef["type"]): string {
|
||||||
case "stamp":
|
case "stamp":
|
||||||
return "#cc0000";
|
return "#cc0000";
|
||||||
case "image":
|
case "image":
|
||||||
|
case "imported":
|
||||||
return "#00000000";
|
return "#00000000";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -495,6 +524,11 @@ export async function addAnnotationDicts(
|
||||||
const pages = pdfDoc.getPages();
|
const pages = pdfDoc.getPages();
|
||||||
|
|
||||||
for (const def of annotations) {
|
for (const def of annotations) {
|
||||||
|
// "imported" annotations are already in the source PDF — never
|
||||||
|
// re-serialize them. Deletion is handled by buildAnnotatedPdfBytes'
|
||||||
|
// removedRefs strip; moving an imported annotation is still UI-only.
|
||||||
|
if (def.type === "imported") continue;
|
||||||
|
|
||||||
const pageIdx = def.page - 1;
|
const pageIdx = def.page - 1;
|
||||||
if (pageIdx < 0 || pageIdx >= pages.length) continue;
|
if (pageIdx < 0 || pageIdx >= pages.length) continue;
|
||||||
const page = pages[pageIdx];
|
const page = pages[pageIdx];
|
||||||
|
|
@ -836,17 +870,62 @@ function setButtonGroupValue(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover the PDF object reference from an annotation id assigned by
|
||||||
|
* `makeAnnotationId`. Handles both `pdf-<num>-<gen>` (from `ann.ref`) and
|
||||||
|
* `pdf-<num>R` (from pdf.js's string `ann.id`, gen always 0). Returns null for
|
||||||
|
* page-index fallback ids — those have no stable ref to remove by.
|
||||||
|
*/
|
||||||
|
export function parseAnnotationRef(
|
||||||
|
id: string,
|
||||||
|
): { objectNumber: number; generationNumber: number } | null {
|
||||||
|
let m = /^pdf-(\d+)-(\d+)$/.exec(id);
|
||||||
|
if (m) return { objectNumber: +m[1], generationNumber: +m[2] };
|
||||||
|
m = /^pdf-(\d+)R$/.exec(id);
|
||||||
|
if (m) return { objectNumber: +m[1], generationNumber: 0 };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build annotated PDF bytes from the original document.
|
* Build annotated PDF bytes from the original document.
|
||||||
* Applies user annotations and form fills, returns Uint8Array of the new PDF.
|
* Applies user annotations and form fills, removes baseline annotations the
|
||||||
|
* user deleted, returns Uint8Array of the new PDF.
|
||||||
*/
|
*/
|
||||||
export async function buildAnnotatedPdfBytes(
|
export async function buildAnnotatedPdfBytes(
|
||||||
pdfBytes: Uint8Array,
|
pdfBytes: Uint8Array,
|
||||||
annotations: PdfAnnotationDef[],
|
annotations: PdfAnnotationDef[],
|
||||||
formFields: Map<string, string | boolean>,
|
formFields: Map<string, string | boolean>,
|
||||||
|
removedRefs: { objectNumber: number; generationNumber: number }[] = [],
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true });
|
const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true });
|
||||||
|
|
||||||
|
// Strip baseline annotations the user deleted. We match on object number +
|
||||||
|
// generation against each page's /Annots array entries (PDFRef). We don't
|
||||||
|
// know which page they were on, so scan every page — /Annots arrays are
|
||||||
|
// small and this only runs at save time.
|
||||||
|
if (removedRefs.length > 0) {
|
||||||
|
const wanted = new Set(
|
||||||
|
removedRefs.map((r) => `${r.objectNumber} ${r.generationNumber}`),
|
||||||
|
);
|
||||||
|
for (const page of pdfDoc.getPages()) {
|
||||||
|
const annots = page.node.Annots();
|
||||||
|
if (!annots) continue;
|
||||||
|
// Walk backwards so .remove(idx) doesn't shift unprocessed entries.
|
||||||
|
for (let i = annots.size() - 1; i >= 0; i--) {
|
||||||
|
const ref = annots.get(i) as {
|
||||||
|
objectNumber?: number;
|
||||||
|
generationNumber?: number;
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
ref?.objectNumber !== undefined &&
|
||||||
|
wanted.has(`${ref.objectNumber} ${ref.generationNumber ?? 0}`)
|
||||||
|
) {
|
||||||
|
annots.remove(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add proper PDF annotation objects
|
// Add proper PDF annotation objects
|
||||||
await addAnnotationDicts(pdfDoc, annotations);
|
await addAnnotationDicts(pdfDoc, annotations);
|
||||||
|
|
||||||
|
|
@ -1023,14 +1102,44 @@ export function importPdfjsAnnotation(
|
||||||
pageNum: number,
|
pageNum: number,
|
||||||
index: number,
|
index: number,
|
||||||
): PdfAnnotationDef | null {
|
): PdfAnnotationDef | null {
|
||||||
const ourType = PDFJS_TYPE_MAP[ann.annotationType];
|
// Skip form widgets (they're handled separately by AnnotationLayer) and
|
||||||
if (!ourType) return null;
|
// auxiliary types that aren't user-visible markup:
|
||||||
|
// 2 = Link (navigational, AnnotationLayer handles the click target)
|
||||||
// Skip form widgets (they're handled separately by AnnotationLayer)
|
// 16 = Popup (the speech-bubble UI for a parent annotation, not content)
|
||||||
if (ann.annotationType === 20) return null;
|
if (
|
||||||
|
ann.annotationType === 20 ||
|
||||||
|
ann.annotationType === 2 ||
|
||||||
|
ann.annotationType === 16
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const id = makeAnnotationId(ann, pageNum, index);
|
const id = makeAnnotationId(ann, pageNum, index);
|
||||||
const color = pdfjsColorToHex(ann.color);
|
const color = pdfjsColorToHex(ann.color);
|
||||||
|
const ourType = PDFJS_TYPE_MAP[ann.annotationType];
|
||||||
|
|
||||||
|
// Anything we don't model — and stamps whose visual is an appearance
|
||||||
|
// stream we can't reproduce as a text label — are kept as "imported":
|
||||||
|
// a placement-only record that the renderer fills with the rasterized
|
||||||
|
// appearance from pdf.js's annotationCanvasMap. This keeps Ink, Polygon,
|
||||||
|
// image-signature stamps, etc. visible AND selectable in our layer.
|
||||||
|
const importAsBitmap =
|
||||||
|
!ourType || (ourType === "stamp" && ann.hasAppearance);
|
||||||
|
if (importAsBitmap) {
|
||||||
|
if (!ann.rect) return null;
|
||||||
|
const r = pdfjsRectToRect(ann.rect);
|
||||||
|
return {
|
||||||
|
type: "imported",
|
||||||
|
id,
|
||||||
|
page: pageNum,
|
||||||
|
x: r.x,
|
||||||
|
y: r.y,
|
||||||
|
width: r.width,
|
||||||
|
height: r.height,
|
||||||
|
pdfjsId: String(ann.id ?? ""),
|
||||||
|
subtype: String(ann.subtype ?? `type${ann.annotationType}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
switch (ourType) {
|
switch (ourType) {
|
||||||
case "highlight":
|
case "highlight":
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue