diff --git a/scripts/smoke-image-insertion.mjs b/scripts/smoke-image-insertion.mjs new file mode 100644 index 0000000..058bc49 --- /dev/null +++ b/scripts/smoke-image-insertion.mjs @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { degrees, PDFDocument, rgb, StandardFonts } from "pdf-lib"; + +export async function imagePlacementPdf() { + const pdf = await PDFDocument.create(); + const font = await pdf.embedFont(StandardFonts.Helvetica); + const cover = pdf.addPage([480, 320]); + cover.drawText("Field notes", { x: 32, y: 270, size: 24, font, color: rgb(0.12, 0.18, 0.18) }); + const next = pdf.addPage([420, 530]); + next.setCropBox(20, 15, 350, 460); + next.setRotation(degrees(90)); + return pdf.save(); +} + +export async function checkImageInsertion(app, page, folder, shots) { + const open = (name, bytes) => app.evaluate(({ BrowserWindow }, { name, bytes }) => + BrowserWindow.getAllWindows()[0].webContents.send("folio:open-file", { name, data: Buffer.from(bytes) }), + { name, bytes: Array.from(bytes) }); + await open("Field notes.pdf", await imagePlacementPdf()); + await page.window.waitForFunction(() => document.title.startsWith("Field notes.pdf")); + const edit = page.getByRole("button", { name: "Edit mode", exact: true }); + await edit.click(); + const images = await page.evaluate(() => { + const make = (width, height, type) => { + const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; + const g = canvas.getContext("2d"); + g.fillStyle = type === "jpeg" ? "#147d75" : "#da7224"; g.fillRect(0, 0, width, height); + if (type === "png") g.clearRect(0, 0, width/3, height/3); + else { + g.fillStyle = "#f5eed5"; g.beginPath(); g.moveTo(30,78); g.lineTo(67,34); g.lineTo(112,78); g.fill(); + g.beginPath(); g.arc(124,24,10,0,2*Math.PI); g.fill(); + } + if (type === "jpeg") { + // Camera bitmap stored sideways; the EXIF tag below rotates it upright. + const raw = document.createElement("canvas"); raw.width = height; raw.height = width; + const ctx = raw.getContext("2d"); ctx.translate(0, width); ctx.rotate(-Math.PI/2); ctx.drawImage(canvas,0,0); + return raw.toDataURL("image/jpeg", 0.95).split(",")[1]; + } + return canvas.toDataURL("image/png").split(",")[1]; + }; + return { jpeg: make(160,96,"jpeg"), png: make(80,48,"png") }; + }); + await page.getByRole("button", { name: "Field notes", exact: true }).click(); + await page.getByRole("textbox", { name: "Edit text on page" }).fill("Field observations"); + const upload = page.getByLabel("Image to insert"); + // Exercise the toolbar without opening a native file chooser on the user's desktop. + await upload.evaluate(input => { input.click = () => input.dataset.opened = "true"; }); + await page.getByRole("button", { name: "Insert image", exact: true }).click(); + assert.equal(await upload.getAttribute("data-opened"), "true"); + const jpeg = Buffer.from(images.jpeg,"base64"); + const exif = Buffer.from("ffe1002245786966000049492a0008000000010012010300010000000600000000000000","hex"); + await upload.setInputFiles({ name: "landscape.jpg", mimeType: "image/jpeg", buffer: Buffer.concat([jpeg.subarray(0,2),exif,jpeg.subarray(2)]) }); + const frame = page.getByRole("group", { name: "Move inserted image" }); + await frame.waitFor(); + await page.getByRole("button", { name: "Field observations", exact: true }).waitFor(); + const drag = async (node, dx, dy) => { + const b = await node.boundingBox(), x = b.x+b.width/2, y = b.y+b.height/2; + await node.hover(); + await page.mouse.down(); + await page.mouse.move(x+dx, y+dy, { steps: 6 }); + await page.mouse.up(); + }; + const relative = () => frame.evaluate(el => { + const box = el.getBoundingClientRect(), host = el.parentElement.getBoundingClientRect(); + return [(box.left-host.left)/host.width,(box.top-host.top)/host.height,box.width/host.width,box.height/host.height]; + }); + const start = await relative(); + await drag(frame, -35, 13); + const moved = await relative(); + assert.ok(moved[0] < start[0] && moved[1] > start[1]); + const corner = page.getByRole("button", { name: "Resize image from bottom right" }); + await drag(corner, 36, 22); + const first = await relative(); + assert.ok(first[2] > moved[2] && first[3] > moved[3]); + assert.ok(Math.abs(first[0]-moved[0]) < 0.005 && Math.abs(first[1]-moved[1]) < 0.005); + assert.ok(Math.abs(first[2]/first[3]-start[2]/start[3]) < 0.01); + await page.screenshot({ path: path.join(shots,"insert-image-after.png") }); + await page.getByRole("button", { name: "Place image", exact: true }).click(); + await page.getByRole("button", { name: "Image 1", exact: true }).first().waitFor(); + await page.getByTitle("Next page").click(); + await page.locator('.page[data-page-number="2"] .folio-content-layer').waitFor(); + const paste = async () => page.evaluate(base64 => { + const data = new DataTransfer(); + data.items.add(new File([Uint8Array.from(atob(base64), ch => ch.charCodeAt(0))], "transparent.png", { type:"image/png" })); + return !document.body.dispatchEvent(new ClipboardEvent("paste", { clipboardData: data, bubbles:true, cancelable:true })); + }, images.png); + assert.ok(await paste()); + await frame.waitFor(); + await page.getByRole("toolbar", { name: "Image placement" }).getByRole("button", { name: "Cancel" }).click(); + assert.equal(await frame.count(), 0); + assert.ok(await paste()); + await frame.waitFor(); + await drag(frame, 21, -9); + await drag(page.getByRole("button", { name: "Resize image from top left" }), -24, -15); + const second = await relative(); + const file = path.join(folder,"images-added.pdf"); + await app.evaluate(({session}, file) => { + globalThis.savedImageCopy = new Promise(resolve => session.defaultSession.once("will-download", (_,item) => { + item.setSavePath(file); item.once("done", (_,state) => resolve(state)); + })); + },file); + // Save while the pasted image is still selected. + await page.getByRole("button", { name: "Save copy", exact: true }).click(); + assert.equal(await app.evaluate(() => globalThis.savedImageCopy), "completed"); + const bytes = readFileSync(file); + const bounds = await page.evaluate(async bytes => { + const task = pdfjsLib.getDocument({ data: Uint8Array.from(bytes) }); + try { + const doc = await task.promise; + const output = []; + for (let n = 1; n <= doc.numPages; n++) { + const p = await doc.getPage(n), viewport = p.getViewport({ scale: 2 }); + const canvas = document.createElement("canvas"); canvas.width = viewport.width; canvas.height = viewport.height; + await p.render({ canvas, viewport }).promise; + const g = canvas.getContext("2d"), {width:w,height:h} = canvas, pixels = g.getImageData(0,0,w,h).data; + // Text antialiasing can produce colored pixels too. Measure the largest + // connected patch of the image color, independently of its expected position. + const mask = new Uint8Array(w*h); + for (let pos = 0; pos < mask.length; pos++) { + const i = 4*pos, red = pixels[i], green = pixels[i+1], blue = pixels[i+2]; + mask[pos] = Number(n === 1 ? green-red > 55 && blue-red > 50 : red-green > 65 && green-blue > 50); + } + const queue = new Uint32Array(mask.length); + let largest = 0, measured; + for (let seed = 0; seed < mask.length; seed++) { + if (!mask[seed]) continue; + let count = 0, left = w, top = h, right = -1, bottom = -1; + const visit = pos => { + if (mask[pos]) { mask[pos] = 0; queue[count++] = pos; } + }; + visit(seed); + for (let head = 0; head < count; head++) { + const pos = queue[head], x = pos % w, y = Math.floor(pos / w); + left = Math.min(left, x); right = Math.max(right, x); + top = Math.min(top, y); bottom = Math.max(bottom, y); + if (x > 0) visit(pos-1); + if (x+1 < w) visit(pos+1); + if (y > 0) visit(pos-w); + if (y+1 < h) visit(pos+w); + } + if (count > largest) { largest = count; measured = [left, top, right, bottom]; } + } + if (!measured) throw new Error("Image color missing on page "+n); + const [l,t,r,b] = measured; + const clear = Array.from(g.getImageData(Math.round(l+(r-l)/6),Math.round(t+(b-t)/6),1,1).data); + const sun = Array.from(g.getImageData(Math.round(l+(r-l)*124/160),Math.round(t+(b-t)*24/96),1,1).data); + output.push({ box:[l/w,t/h,(r-l+1)/w,(b-t+1)/h], clear, sun }); + } + return output; + } finally { await task.destroy(); } + },Array.from(bytes)); + for (const [i,expected] of [first,second].entries()) { + for (const [k,v] of expected.entries()) assert.ok(Math.abs(v-bounds[i].box[k])<0.008, JSON.stringify({ i, expected, actual:bounds[i].box })); + } + assert.ok(bounds[0].sun[0] > 220 && bounds[0].sun[1] > 220 && bounds[0].sun[2] < 240, "camera JPEG lost its orientation"); + assert.deepEqual(bounds[1].clear,[255,255,255,255]); + await page.window.waitForFunction(() => document.title.startsWith("Field notes (edited).pdf")); + await open("Reopened images.pdf",bytes); + await page.window.waitForFunction(() => document.title.startsWith("Reopened images.pdf")); + await edit.click(); + // Folio restores the saved position. Edit targets appear when their page is in view. + const pageNumber = page.getByLabel("Page number", { exact: true }); + for (const number of [1, 2]) { + await pageNumber.fill(String(number)); + await pageNumber.press("Enter"); + await page.locator(`.page[data-page-number="${number}"]`).getByRole("button", { name: "Image 1", exact: true }).waitFor(); + } + console.log("ok JPEG and pasted transparent PNG match the dragged and resized previews after save, including on a rotated cropped page"); +} diff --git a/scripts/smoke-reader-tools.mjs b/scripts/smoke-reader-tools.mjs index bb1abea..411e127 100644 --- a/scripts/smoke-reader-tools.mjs +++ b/scripts/smoke-reader-tools.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { checkImageInsertion } from "./smoke-image-insertion.mjs"; import { readFileSync } from "node:fs"; import path from "node:path"; import { deflateSync } from "node:zlib"; @@ -1057,6 +1058,7 @@ export async function checkReaderTools(app, page, folder, shots) { console.log("ok the normal Save shortcut and Open reordered copy preserve filled form fields"); await checkScanSelection(app, page); await checkContentEditing(app, page, folder, shots); + await checkImageInsertion(app, page, folder, shots); await checkRightToLeftEditing(app, page, shots); await checkArabicEditing(app, page, folder, shots); await checkLinkHistory(app, page, shots); diff --git a/src/App.tsx b/src/App.tsx index 6aae8a5..56bfcb0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,6 +37,7 @@ import { ZoomMenu } from "./ZoomMenu"; import { SearchBar, type FindResult } from "./SearchBar"; import { AboutDialog, InfoDialog, OpenUrlDialog, PasswordDialog, ShortcutsDialog, parseWebUrl } from "./Dialogs"; import { SignatureDialog } from "./SignatureDialog"; +import { newImageDraft, type ImageDraft } from "./ImagePlacement"; import { ContentEditor, type ContentEditorHandle, type NextSelection } from "./ContentEditor"; import { VersionHistoryPanel } from "./VersionHistoryPanel"; import { saveVersion, type PdfChange } from "./versionHistory"; @@ -220,6 +221,9 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs const markDirty = useCallback(() => { editRevision.current++; setDirty(true); }, []); const [contentEditing, setContentEditing] = useState(false); const contentRef = useRef(null); + const imageFile = useRef(null); + const imageLoad = useRef(0); + const [imageDraft, setImageDraft] = useState(null); const nextSelection = useRef(null); const [editorDirty, setEditorDirty] = useState(false); const [pageDraft, setPageDraft] = useState<{ order: number[]; copy?: Uint8Array } | null>(null); @@ -504,6 +508,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs setDoc(next); if (!revision) { nextSelection.current = null; setContentEditing(false); } setEditorDirty(false); + setImageDraft(null); setPageDraft(draft => revision && draft ? { order: draft.order } : null); setNumPages(pdf.numPages); setPage(lastPageRef.current); @@ -694,6 +699,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs }; const chooseTool = async (next: Tool, editContent = false) => { + if (!editContent) imageLoad.current++; if (contentRef.current && !(await contentRef.current.finish())) return false; const v = viewerRef.current!; if (!v.pdfDocument) return false; @@ -711,6 +717,34 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs } catch { flash("Annotations aren’t supported for this document"); return false; } }; const leaveEditMode = () => chooseTool("select"); + const insertImage = useCallback(async (file: File) => { + const editor = contentRef.current, owner = docRef.current, viewer = viewerRef.current; + if (!editor || !owner || !viewer) return; + const request = ++imageLoad.current, page = viewer.currentPageNumber; + const pageView = viewer.getPageView(page-1); + const sheet = pageView?.div.querySelector(".canvasWrapper")?.getBoundingClientRect(); + const view = containerRef.current?.getBoundingClientRect(); + try { + // Measure the original page before committing a text edit, which replaces the viewer document. + const viewport = pageView?.viewport ?? (await owner.pdf.getPage(page)).getViewport({ scale: 1 }); + let visible; + if (sheet?.width && sheet.height && view) { + const left = Math.max(sheet.left, view.left), top = Math.max(sheet.top, view.top); + const width = Math.min(sheet.right, view.right)-left, height = Math.min(sheet.bottom, view.bottom)-top; + if (width > 0 && height > 0) visible = new DOMRect((left-sheet.left)*viewport.width/sheet.width, + (top-sheet.top)*viewport.height/sheet.height, width*viewport.width/sheet.width, height*viewport.height/sheet.height); + } + const draft = await newImageDraft(file, page, viewport, visible); + if (request !== imageLoad.current || docRef.current !== owner || !contentRef.current) return; + if (!(await contentRef.current.finish())) return; + const current = docRef.current; + if (request === imageLoad.current && current && (current.sourceId ?? current.id) === (owner.sourceId ?? owner.id)) { + setImageDraft(draft); + } + } catch (err) { + if (request === imageLoad.current && docRef.current === owner) flash(err instanceof Error ? err.message : "Couldn't insert this image."); + } + }, [flash]); const startSigning = async () => { if (await chooseTool("select")) setSigning(true); }; useEffect(() => { @@ -1305,6 +1339,8 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs // Pasting a link (outside text fields) fills in Open from URL too. const paste = (e: ClipboardEvent) => { if (signing || (e.target as HTMLElement | null)?.closest?.("input, textarea, [contenteditable=true], .freeTextEditor")) return; + const image = contentRef.current && Array.from(e.clipboardData?.files ?? []).find(file => /^image\/(png|jpeg)$/i.test(file.type)); + if (image) { e.preventDefault(); void insertImage(image); return; } const url = parseWebUrl(e.clipboardData?.getData("text/plain")); if (!url) return; e.preventDefault(); @@ -1326,7 +1362,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs window.removeEventListener("dragover", over); window.removeEventListener("drop", drop); }; - }, [openFiles, signing]); + }, [openFiles, signing, insertImage]); const runFind = (query: string, opts: { again?: boolean; previous?: boolean; caseSensitive?: boolean; entireWord?: boolean }) => busRef.current!.dispatch("find", { @@ -1393,6 +1429,14 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs onClick={() => contentEditing ? leaveEditMode() : chooseTool("select", true)}> {contentEditing ? "Exit edit mode" : "Edit mode"} + {contentEditing && <> + + { + const file = event.target.files?.[0]; event.target.value = ""; + if (file) void insertImage(file); + }} /> + } {(doc.sourceId || editorDirty) && } )} @@ -1481,7 +1525,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs
{contentEditing && doc && { + imageDraft={imageDraft} setImageDraft={setImageDraft} onDirty={setEditorDirty} snapshot={async () => { if (doc.password || await doc.pdf.getPermissions() || (await doc.pdf.getSignatures())?.length) throw new Error("Editing requires an unsigned, unencrypted PDF."); const copy = await currentBytes(doc, []); if (!copy || docRef.current !== doc) throw new Error("The document changed. Try again."); diff --git a/src/ContentEditor.tsx b/src/ContentEditor.tsx index e2d2840..fb7922b 100644 --- a/src/ContentEditor.tsx +++ b/src/ContentEditor.tsx @@ -5,6 +5,7 @@ import type { PDFDocumentProxy } from "./pdf"; import type { EventBus, PDFViewer } from "./viewer"; import type { Change, EditorReply, EditorRequest, EditTarget } from "./contentEditor.worker"; import type { PdfChange } from "./versionHistory"; +import { ImagePlacement, type ImageDraft } from "./ImagePlacement"; import { isArabicText, unsupportedDirection, RIGHT_TO_LEFT_MESSAGE } from "./textDirection"; export interface ContentEditorHandle { finish: () => Promise; deselect: () => boolean } @@ -44,9 +45,10 @@ function inputStyle(t: EditTarget, v: PageViewport, paper: string): CSSPropertie transform: 'rotate(' + angle + 'rad) ' + (s.anchor ? 'translate(-100%, -0.88em)' : 'translateY(-0.88em)') }; } -export function ContentEditor({ ref, nextSelection, pdf, viewer, bus, snapshot, apply, onDirty }: { +export function ContentEditor({ ref, nextSelection, pdf, viewer, bus, snapshot, apply, onDirty, imageDraft, setImageDraft }: { ref: Ref; pdf: PDFDocumentProxy; viewer: PDFViewer; bus: EventBus; nextSelection: RefObject; + imageDraft: ImageDraft | null; setImageDraft: (draft: ImageDraft | null) => void; snapshot: () => Promise; apply: (bytes: Uint8Array, change: PdfChange) => Promise; onDirty: (dirty: boolean) => void; }) { const [surfaces, setSurfaces] = useState([]); @@ -61,10 +63,13 @@ export function ContentEditor({ ref, nextSelection, pdf, viewer, bus, snapshot, const worker = useRef(null); const waiter = useRef<{ resolve: (data: Uint8Array) => void; reject: (reason: Error) => void } | null>(null); const pending = useRef | null>(null); + const draftRef = useRef(imageDraft); + draftRef.current = imageDraft; const selectedRef = useRef(selected); selectedRef.current = selected; const modified = selected?.target.kind === "text" && value !== selected.target.label; - useEffect(() => { onDirty(!!modified || busy); return () => onDirty(false); }, [modified, busy, onDirty]); + const dirty = !!modified || !!imageDraft || busy; + useEffect(() => { onDirty(dirty); return () => onDirty(false); }, [dirty, onDirty]); useEffect(() => { const w = new Worker(new URL("./contentEditor.worker.ts", import.meta.url), { type: "module" }); @@ -76,6 +81,7 @@ export function ContentEditor({ ref, nextSelection, pdf, viewer, bus, snapshot, const visible = viewer._getVisiblePages() as { views?: { id: number }[] }; const pages = new Set(visible.views?.map(v => v.id)); if (selectedRef.current) pages.add(selectedRef.current.page); + if (draftRef.current) pages.add(draftRef.current.page); const next: Surface[] = []; for (const page of pages) { const view = viewer.getPageView(page-1); @@ -124,10 +130,10 @@ export function ContentEditor({ ref, nextSelection, pdf, viewer, bus, snapshot, if (selected?.target.kind === "text") { input.current?.focus({ preventScroll: true }); input.current?.select(); } }, [selected]); - const commit = (change: Change): Promise => { + const commit = (change: Change, page = selected?.page): Promise => { if (pending.current) return pending.current; const w = worker.current; - if (!w || !selected) return Promise.resolve(false); + if (!w || !page) return Promise.resolve(false); setBusy(true); setError(""); const transaction = (async () => { try { @@ -135,11 +141,11 @@ export function ContentEditor({ ref, nextSelection, pdf, viewer, bus, snapshot, if (worker.current !== w) return false; const result = await new Promise((resolve,reject) => { waiter.current = { resolve, reject }; - w.postMessage({ page: selected.page, change, snapshot: data } satisfies EditorRequest); + w.postMessage({ page, change, snapshot: data } satisfies EditorRequest); }); const detail: PdfChange = change.kind === "text" - ? { kind: "text", page: selected.page, before: selected.target.label, after: change.text } - : { kind: "image", page: selected.page, fileName: change.file.name }; + ? { kind: "text", page, before: selected!.target.label, after: change.text } + : { kind: "image", page, fileName: change.file.name, inserted: change.kind === "insertImage" }; return worker.current === w && await apply(result, detail); } catch (e) { if (worker.current === w) { setError(e instanceof Error ? e.message : "Couldn't apply the edit."); input.current?.focus({ preventScroll: true }); } @@ -150,16 +156,19 @@ export function ContentEditor({ ref, nextSelection, pdf, viewer, bus, snapshot, }; const finish = () => { if (pending.current) return pending.current; + if (imageDraft) return commit({ kind: "insertImage", file: imageDraft.file, box: imageDraft.box, rotation: imageDraft.rotation }, imageDraft.page); if (selected && modified) return commit({ kind: "text", index: selected.target.index, text: value }); setSelected(null); return Promise.resolve(true); }; useImperativeHandle(ref, () => ({ finish, deselect: () => { - if (!selected || pending.current) return false; + if (pending.current) return false; + if (imageDraft) { setImageDraft(null); setError(""); return true; } + if (!selected) return false; selectedRef.current = null; setSelected(null); setError(""); return true; } })); const pick = (s: Surface, target: EditTarget) => { - if (pending.current) return; + if (pending.current || imageDraft) return; if (target.kind === "text" && unsupportedDirection(target.label)) { selectedRef.current = null; setSelected(null); setError(RIGHT_TO_LEFT_MESSAGE); return; @@ -196,7 +205,7 @@ export function ContentEditor({ ref, nextSelection, pdf, viewer, bus, snapshot, useEffect(() => { if (!selected) return; const outside = (e: PointerEvent) => { - if (!pending.current && e.target instanceof Element && !e.target.closest(".folio-inline-text, .folio-image-actions, .folio-content-target")) void finish(); + if (!pending.current && e.target instanceof Element && !e.target.closest(".folio-inline-text, .folio-image-actions, .folio-content-target, [data-insert-image]")) void finish(); }; document.addEventListener("pointerdown", outside); return () => document.removeEventListener("pointerdown", outside); @@ -213,13 +222,14 @@ export function ContentEditor({ ref, nextSelection, pdf, viewer, bus, snapshot, {(targets.get(s.page) ?? []).map(t => + + }
{error || (busy ? "Applying edit…" : - surfaces.length > 0 && surfaces.every(s => targets.get(s.page)?.length === 0) ? "No editable text or images on this page." : "")}
+ !imageDraft && surfaces.length > 0 && surfaces.every(s => targets.get(s.page)?.length === 0) ? "No editable text or images on this page." : "")} ; } diff --git a/src/ImagePlacement.tsx b/src/ImagePlacement.tsx new file mode 100644 index 0000000..c186723 --- /dev/null +++ b/src/ImagePlacement.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react"; +import { checkImageDimensions, checkImageFileSize } from "./pdfInput"; +import type { PageViewport } from "pdfjs-dist"; + +export type ImageBox = [number, number, number, number]; +export interface ImageDraft { file: File; page: number; rotation: number; box: ImageBox } + +const corners = [ + ["nw", "top left"], ["ne", "top right"], ["sw", "bottom left"], ["se", "bottom right"], +] as const; +type Handle = typeof corners[number][0] | "move"; +const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)); + +function pdfBox(viewport: PageViewport, left: number, top: number, right: number, bottom: number): ImageBox { + const [x1,y1] = viewport.convertToPdfPoint(left, top); + const [x2,y2] = viewport.convertToPdfPoint(right, bottom); + return [Math.min(x1,x2), Math.min(y1,y2), Math.max(x1,x2), Math.max(y1,y2)]; +} + +export async function newImageDraft(file: File, page: number, viewport: PageViewport, visible?: DOMRect): Promise { + checkImageFileSize(file.size); + const header = new Uint8Array(await file.slice(0, 24).arrayBuffer()); + const png = header.length === 24 && new DataView(header.buffer).getUint32(0) === 0x89504e47; + if (!png && !(header[0] === 0xff && header[1] === 0xd8)) throw new Error("Choose a PNG or JPEG image."); + if (png) { + const view = new DataView(header.buffer); + checkImageDimensions(view.getUint32(16), view.getUint32(20)); + } + const bitmap = await createImageBitmap(file).catch(() => { throw new Error("Couldn't read this image."); }); + let width, height; + try { + checkImageDimensions(bitmap.width, bitmap.height); + const scale = Math.min(viewport.scale*0.75, viewport.width*0.5/bitmap.width, viewport.height*0.5/bitmap.height); + width = bitmap.width*scale; height = bitmap.height*scale; + } finally { bitmap.close(); } + const cx = visible ? visible.x + visible.width/2 : viewport.width/2; + const cy = visible ? visible.y + visible.height/2 : viewport.height/2; + const left = clamp(cx-width/2, 0, viewport.width-width); + const top = clamp(cy-height/2, 0, viewport.height-height); + return { file, page, rotation: viewport.rotation, box: pdfBox(viewport, left, top, left+width, top+height) }; +} + +function rectangle(viewport: PageViewport, box: ImageBox) { + const [x1,y1] = viewport.convertToViewportPoint(box[0], box[1]); + const [x2,y2] = viewport.convertToViewportPoint(box[2], box[3]); + return { left: Math.min(x1,x2), top: Math.min(y1,y2), width: Math.abs(x2-x1), height: Math.abs(y2-y1) }; +} + +function shifted(box: ImageBox, viewport: PageViewport, handle: Handle, dx: number, dy: number) { + const { left, top, width, height } = rectangle(viewport, box); + if (handle === "move") { + const x = clamp(left+dx, 0, viewport.width-width), y = clamp(top+dy, 0, viewport.height-height); + return pdfBox(viewport, x, y, x+width, y+height); + } + const west = handle.endsWith("w"), north = handle.startsWith("n"); + const fixedX = west ? left+width : left, fixedY = north ? top+height : top; + // Project the drag onto the diagonal so the opposite corner and the aspect ratio stay fixed. + const scale = 1 + ((west ? -dx : dx)*width + (north ? -dy : dy)*height)/(width*width + height*height); + const max = Math.min((west ? fixedX : viewport.width-fixedX)/width, (north ? fixedY : viewport.height-fixedY)/height); + const fit = clamp(scale, Math.min(max, 1, 8/Math.min(width,height)), max); + return pdfBox(viewport, fixedX, fixedY, fixedX+(west ? -1 : 1)*width*fit, fixedY+(north ? -1 : 1)*height*fit); +} + +export function ImagePlacement({ draft, viewport, onChange, disabled }: { + draft: ImageDraft; viewport: PageViewport; onChange: (draft: ImageDraft) => void; disabled: boolean; +}) { + const [src, setSrc] = useState(""); + const frame = useRef(null); + const drag = useRef<{ id: number; handle: Handle; x: number; y: number; box: ImageBox } | null>(null); + useEffect(() => { + const url = URL.createObjectURL(draft.file); + setSrc(url); + frame.current?.focus({ preventScroll: true }); + return () => URL.revokeObjectURL(url); + }, [draft.file]); + const rect = rectangle(viewport, draft.box); + const angle = (viewport.rotation-draft.rotation+360)%360; + const change = (box: ImageBox) => onChange({ ...draft, box }); + const down = (event: PointerEvent, handle: Handle) => { + if (disabled || event.button !== 0 || drag.current) return; + event.preventDefault(); event.stopPropagation(); + event.currentTarget.focus({ preventScroll: true }); + event.currentTarget.setPointerCapture(event.pointerId); + drag.current = { id: event.pointerId, handle, x: event.clientX, y: event.clientY, box: draft.box }; + }; + const move = (event: PointerEvent) => { + const start = drag.current, host = frame.current?.parentElement?.getBoundingClientRect(); + if (disabled || !start || start.id !== event.pointerId || !host?.width || !host.height) return; + change(shifted(start.box, viewport, start.handle, (event.clientX-start.x)*viewport.width/host.width, + (event.clientY-start.y)*viewport.height/host.height)); + }; + const key = (event: KeyboardEvent, handle: Handle) => { + if (disabled || !["ArrowLeft","ArrowRight","ArrowUp","ArrowDown"].includes(event.key)) return; + event.stopPropagation(); event.preventDefault(); + const step = (event.shiftKey ? 10 : 1)*viewport.scale; + change(shifted(draft.box, viewport, handle, event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0, + event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0)); + }; + return
down(e, "move")} onPointerMove={move} onPointerUp={e => { + if (drag.current?.id === e.pointerId) { move(e); drag.current = null; } + }} onPointerCancel={e => { + if (drag.current?.id === e.pointerId) { change(drag.current.box); drag.current = null; } + }} onLostPointerCapture={() => { drag.current = null; }} onKeyDown={e => key(e, "move")}> + {src && } + {corners.map(([handle,label]) =>
; +} diff --git a/src/VersionHistoryPanel.tsx b/src/VersionHistoryPanel.tsx index 5558503..b0ecd61 100644 --- a/src/VersionHistoryPanel.tsx +++ b/src/VersionHistoryPanel.tsx @@ -6,7 +6,7 @@ function Changes({ changes }: { changes: PdfChange[] }) { return
    {changes.map((change, i) =>
  • - {change.kind === "text" ? change.after ? "Edited text" : "Removed text" : change.kind === "image" ? "Replaced image" : + {change.kind === "text" ? change.after ? "Edited text" : "Removed text" : change.kind === "image" ? change.inserted ? "Inserted image" : "Replaced image" : change.kind === "pages" ? "Reordered pages" : "Updated annotations or fields"} {"page" in change && Page {change.page}}
    diff --git a/src/contentEditor.worker.ts b/src/contentEditor.worker.ts index 9cc2e2e..f0087d5 100644 --- a/src/contentEditor.worker.ts +++ b/src/contentEditor.worker.ts @@ -2,7 +2,7 @@ import { arabicLine } from "./arabicLayout"; import { init, type WrappedPdfiumModule } from "@embedpdf/pdfium"; import wasmUrl from "@embedpdf/pdfium/pdfium.wasm?url"; import { checkPdfSize } from "./pdfInput"; -import { replacePageImage } from "./pdfImageEdit"; +import { replacePageImage, insertPageImage } from "./pdfImageEdit"; import { isArabicText, unsupportedDirection, RIGHT_TO_LEFT_MESSAGE } from "./textDirection"; export interface EditTarget { @@ -16,7 +16,8 @@ export interface EditTarget { box: number[]; style?: { matrix?: number[]; anchor?: number[]; origin: number[]; axis: number[]; size: number; flags: number; bold: boolean; color: string }; } -export type Change = { kind: "text"; index: number; text: string } | { kind: "image"; index: number; file: File }; +export type Change = { kind: "text"; index: number; text: string } | { kind: "image"; index: number; file: File } + | { kind: "insertImage"; file: File; box: number[]; rotation: number }; export type EditorRequest = { source: Uint8Array } | { page: number } | { page: number; change: Change; snapshot: Uint8Array }; export type EditorReply = { page: number; targets: EditTarget[] } | { bytes: Uint8Array } | { error: string; page?: number }; @@ -306,6 +307,7 @@ self.onmessage = async ({ data }: MessageEvent) => { } const { change, snapshot } = data; const result = withPage(m, snapshot, page, async (doc, handle) => { + if (change.kind === "insertImage") return insertPageImage(snapshot, page, change.file, change.box, change.rotation); const { targets, imageCount } = pageView(m, handle); const target = targets.find(t => t.index === change.index && t.kind === change.kind); if (!target) throw new Error("Select the item again."); diff --git a/src/pdfImageEdit.ts b/src/pdfImageEdit.ts index 507d430..f2d3fb1 100644 --- a/src/pdfImageEdit.ts +++ b/src/pdfImageEdit.ts @@ -1,6 +1,6 @@ -import { decodePDFRawStream, PDFArray, PDFDict, PDFDocument, PDFName, PDFObjectParser, PDFRawStream } from "pdf-lib/es"; +import { concatTransformationMatrix, popGraphicsState, pushGraphicsState, decodePDFRawStream, PDFArray, PDFDict, PDFDocument, PDFName, PDFObjectParser, PDFRawStream } from "pdf-lib/es"; import ByteStream from "pdf-lib/es/core/parser/ByteStream"; -import { checkPdfSize } from "./pdfInput"; +import { checkPdfSize, checkImageDimensions, checkImageFileSize } from "./pdfInput"; const name = PDFName.of; const white = (c: number) => c === 0 || c === 9 || c === 10 || c === 12 || c === 13 || c === 32; @@ -39,8 +39,78 @@ function imageOperands(bytes: Uint8Array, pdf: PDFDocument, resources: PDFDict) return placements; } +// Browser image previews apply EXIF orientation. Keep the original JPEG bytes and reproduce +// that transform in the PDF so camera photos match the preview, including mirrored orientations. +function jpegOrientation(bytes: Uint8Array) { + const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + for (let i = 2; i+4 <= bytes.length && bytes[i] === 0xff;) { + const marker = bytes[i+1]; + if (marker === 0xda || marker === 0xd9) break; + const length = v.getUint16(i+2), end = i+2+length; + if (length < 2 || end > bytes.length) break; + if (marker === 0xe1 && length >= 16 && v.getUint32(i+4) === 0x45786966 && v.getUint16(i+8) === 0) { + const tiff = i+10, little = v.getUint16(tiff) === 0x4949; + if (v.getUint16(tiff+2,little) !== 42) return 1; + const dir = tiff+v.getUint32(tiff+4,little); + if (dir+2 > end) return 1; + const count = v.getUint16(dir,little); + for (let n = 0, p = dir+2; n < count && p+12 <= end; n++, p+=12) { + if (v.getUint16(p,little) === 0x112 && v.getUint16(p+2,little) === 3 && v.getUint32(p+4,little) === 1) { + const value = v.getUint16(p+8,little); + return value >= 1 && value <= 8 ? value : 1; + } + } + } + i = end; + } + return 1; +} + +async function embedRaster(pdf: PDFDocument, file: File) { + checkImageFileSize(file.size); + const data = new Uint8Array(await file.arrayBuffer()); + let image, orientation = 1; + if (data[0] === 0xff && data[1] === 0xd8) { image = await pdf.embedJpg(data); orientation = jpegOrientation(data); } + else if (data.length >= 24 && new DataView(data.buffer).getUint32(0) === 0x89504e47) { + const header = new DataView(data.buffer); + checkImageDimensions(header.getUint32(16), header.getUint32(20)); + image = await pdf.embedPng(data); + } else throw new Error("Choose a PNG or JPEG image."); + checkImageDimensions(image.width, image.height); + + return { image, orientation }; +} + +type Matrix = [number, number, number, number, number, number]; +const photoTransforms: Matrix[] = [ + [1,0,0,1,0,0], [-1,0,0,1,1,0], [-1,0,0,-1,1,1], [1,0,0,-1,0,1], + [0,-1,-1,0,1,1], [0,-1,1,0,0,1], [0,1,1,0,0,0], [0,1,-1,0,1,0], +]; + +export async function insertPageImage(source: Uint8Array, number: number, file: File, box: number[], rotation: number) { + const [l,b,r,t] = box, w = r-l, h = t-b; + if (!box.every(Number.isFinite) || w <= 0 || h <= 0 || ![0,90,180,270].includes(rotation)) throw new Error("Choose a position on the page."); + const pdf = await PDFDocument.load(source, { updateMetadata: false }); + const page = pdf.getPage(number-1); + const { image, orientation } = await embedRaster(pdf, file); + // Copies can share these dictionaries or an entire content-stream array between pages. + const resources = page.node.Resources()?.clone() ?? pdf.context.obj({}); + const xObjects = resources.lookupMaybe(name("XObject"), PDFDict)?.clone() ?? pdf.context.obj({}); + resources.set(name("XObject"), xObjects); + page.node.set(name("Resources"), resources); + const contents = page.node.Contents(); + if (contents instanceof PDFArray) page.node.set(name("Contents"), contents.clone()); + const pageTransforms: Matrix[] = [[w,0,0,h,l,b], [0,h,-w,0,r,b], [-w,0,0,-h,r,t], [0,-h,w,0,l,t]]; + page.pushOperators(pushGraphicsState(), concatTransformationMatrix(...pageTransforms[rotation/90]), + concatTransformationMatrix(...photoTransforms[orientation-1])); + page.drawImage(image, { x: 0, y: 0, width: 1, height: 1 }); + page.pushOperators(popGraphicsState()); + const bytes = await pdf.save({ updateFieldAppearances: false }); + checkPdfSize(bytes.length); + return bytes; +} + export async function replacePageImage(source: Uint8Array, pageNumber: number, ordinal: number, count: number, file: File) { - if (file.size > 24 * 1024 * 1024) throw new Error("Choose an image smaller than 24 MB."); const pdf = await PDFDocument.load(source, { updateMetadata: false }); const page = pdf.getPage(pageNumber - 1).node; const resources = page.Resources()?.clone(); @@ -63,15 +133,7 @@ export async function replacePageImage(source: Uint8Array, pageNumber: number, o const place = placements[ordinal]; if (placements.length !== count || !place) throw new Error("Couldn't identify this image placement reliably."); - const data = new Uint8Array(await file.arrayBuffer()); - let image; - if (data[0] === 0xff && data[1] === 0xd8) image = await pdf.embedJpg(data); - else if (data.length >= 24 && new DataView(data.buffer).getUint32(0) === 0x89504e47) { - const header = new DataView(data.buffer); - if (header.getUint32(16) * header.getUint32(20) > 20_000_000) throw new Error("Choose an image under 20 megapixels."); - image = await pdf.embedPng(data); - } else throw new Error("Choose a PNG or JPEG image."); - if (image.width * image.height > 20_000_000) throw new Error("Choose an image under 20 megapixels."); + const { image } = await embedRaster(pdf, file); // Change only the chosen Do operand. This retains its clipping, tags, transforms and stacking order, // including when other pages share the original image, resources, or even the entire content stream. diff --git a/src/pdfInput.ts b/src/pdfInput.ts index 1cff7b1..0f6f771 100644 --- a/src/pdfInput.ts +++ b/src/pdfInput.ts @@ -81,3 +81,11 @@ export async function readPdfResponse(response: Response, signal: AbortSignal, l reader?.releaseLock(); } } + +export function checkImageFileSize(bytes: number) { + if (bytes > 24 * 1024 * 1024) throw new Error("Choose an image smaller than 24 MB."); +} + +export function checkImageDimensions(width: number, height: number) { + if (width * height > 20_000_000) throw new Error("Choose an image under 20 megapixels."); +} diff --git a/src/styles.css b/src/styles.css index 65015f8..75f1be6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1678,3 +1678,14 @@ kbd { @font-face { font-family: FolioArabic; src: url("dejavu-fonts-ttf/ttf/DejaVuSans.ttf"); } @font-face { font-family: FolioArabic; font-weight: 700; src: url("dejavu-fonts-ttf/ttf/DejaVuSans-Bold.ttf"); } + +.folio-image-placement { position: absolute; z-index: 2; cursor: move; outline: 2px solid #3377ac; touch-action: none; user-select: none; } +.folio-image-placement img { position: absolute; left: 50%; top: 50%; max-width: none; pointer-events: none; } +.folio-image-placement .image-corner { position: absolute; width: 13px; height: 13px; padding: 0; background: white; border: 2px solid #3377ac; border-radius: 3px; touch-action: none; } +.image-corner.nw, .image-corner.sw { left: -6px; } +.image-corner.ne, .image-corner.se { right: -6px; } +.image-corner.nw, .image-corner.ne { top: -6px; } +.image-corner.sw, .image-corner.se { bottom: -6px; } +.image-corner.nw, .image-corner.se { cursor: nwse-resize; } +.image-corner.ne, .image-corner.sw { cursor: nesw-resize; } +.folio-place-actions { position: absolute; z-index: 11; left: 50%; bottom: 76px; transform: translateX(-50%); display: flex; gap: 6px; padding: 5px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); box-shadow: var(--shadow-sm); } diff --git a/src/versionHistory.ts b/src/versionHistory.ts index f0cc064..f463cad 100644 --- a/src/versionHistory.ts +++ b/src/versionHistory.ts @@ -2,7 +2,7 @@ import { openFolioDatabase } from "./recent"; export type PdfChange = | { kind: "text"; page: number; before: string; after: string } - | { kind: "image"; page: number; fileName: string } + | { kind: "image"; page: number; fileName: string; inserted?: boolean } | { kind: "pages"; order: number[] } | { kind: "annotations" };