Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,17 +138,17 @@ The editor works with unsigned, unencrypted PDFs.
Choose **Sign** in the toolbar to add a signature.

- **Capture on paper:** sign with dark ink on white paper, then use your camera or upload a PNG, JPEG, or WebP photo.
- **Clean up the image:** crop to the signature and adjust ink sensitivity to remove the paper background. Capture
and image processing stay on your device.
- **Suggested placement:** Folio checks the current page for empty PDF signature fields and likely signature labels,
then offers places to sign. Detection is a suggestion, not a guarantee that every field will be found.
- **Clean up the image:** Folio removes the paper background and trims the image on your device. Use **Adjust crop and ink** if you need to refine it.
- **Sign a detected field:** click an empty PDF signature field, then choose your saved signature or initials from the picker beside it. The chosen image fits the field. If you have no saved signatures yet, clicking the field opens capture with that field selected. A page with a single field also offers a signing shortcut at the bottom.
- **Suggested placement:** On PDFs with selectable text, click a printed line labeled **Signature** or **Initials** to pick a saved mark beside it. Folio recognizes drawn rules and underscores. When it finds only a possible signing area, **Review signature** lets you check the placement.
- **Preview and position:** review the actual page, pick a suggestion or place the signature manually, and adjust its
width before approving.
- **Explicit approval:** nothing is added until you click **Approve and add signature**. Signatures are visual stamps,
- **Explicit approval:** click the sign button on a detected field or **Approve and add signature** in the preview. Signatures are visual stamps,
not certificate-based digital signatures. Use **Save a copy** to keep a signed PDF; placed signatures are not part
of Folio's automatic annotation restoration.
- **Optional reuse:** choose to remember a signature on this computer; remove the saved image in
**Settings → Storage → Signature → Forget**.
- **Optional reuse:** select **Remember this signature** and give it a name, such as “Initials”.
Open **Sign** again to choose a saved signature or capture another. Remove individual signatures in
**Settings → Storage → Signatures**.

### Ask AI about a document

Expand Down Expand Up @@ -314,8 +314,8 @@ is there when you reopen the document; the **+** button in the panel starts over
Stored locally in your user profile (`%APPDATA%\Folio` on Windows, `~/Library/Application Support/Folio` on macOS,
`~/.config/Folio` on Linux): recently opened PDFs and their thumbnails (up to 8,
removable from the home screen with **Clear all**; password-protected PDFs are never kept), highlights, notes, and
Ask AI conversations for each document (except password-protected ones), reading positions, preferences, and, only if you choose to remember
it, your signature image. Apart from provider credentials, this data isn't encrypted, so anyone with access to your
Ask AI conversations for each document (except password-protected ones), reading positions, preferences, and any
signature images you choose to remember. Apart from provider credentials, this data isn't encrypted, so anyone with access to your
user account can read it.

## Building from source
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Known limitations:
(`01AF 1CBF 971E 5482 C064 1A6E EE00 67F5 E3B5 48FC`). These keys are installed by the .deb as
`/usr/share/keyrings/folio-archive-keyring.gpg` and used only for Folio's source (`signed-by`). apt rejects any
repository metadata not signed with it.
- Local data (recent files, reading positions, an optionally remembered signature) is stored unencrypted in your user
- Local data (recent files, reading positions, and any signatures you choose to remember) is stored unencrypted in your user
profile. See [Privacy](README.md#privacy).
- Word and LibreOffice convert documents with your own permissions and aren't sandboxed further by Folio. Word is
driven through automation, which doesn't use its Protected View. Neither is cut off from the network.
184 changes: 184 additions & 0 deletions scripts/smoke-reader-tools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,189 @@ async function captureDownload(app, file) {
}, file);
}

async function checkSignatureFields(app, page, folder) {
const labels = "BT /F 12 Tf 30 190 Td (Approved by:) Tj 0 -110 Td (Initials) Tj ET";
const lines = [
"q 1 0 0 1 30 225 cm 0 0 m 200 0 l S Q BT /F 12 Tf 30 208 Td (Signature) Tj ET",
"BT /F 12 Tf 30 140 Td (Initials: ____________) Tj ET",
"30 65 m 170 65 l S BT /F 12 Tf 30 50 Td (Witness signature) Tj ET",
"BT /F 10 Tf 30 20 Td (Your signature confirms approval.) Tj ET",
].join("\n");
// Page 4 has no PDF signature field: a sentence that mentions signing, then a captioned rule.
const prose = [
"BT /F 10 Tf 30 250 Td (This agreement may be amended only by a) Tj 0 -14 Td (writing signed by both parties.) Tj ET",
"30 65 m 170 65 l S BT /F 12 Tf 30 50 Td (Witness signature) Tj ET",
].join("\n");
const form = testPdf([
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 8 0 R 11 0 R] >> >>",
"<< /Type /Pages /Count 4 /Kids [3 0 R 4 0 R 9 0 R 12 0 R] >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [20 30 320 330] /Rotate 90 /Annots [5 0 R 8 0 R] >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Resources << /Font << /F 6 0 R >> >> /Contents 7 0 R >>",
"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Applicant signature) /Rect [30 110 230 155] /P 3 0 R /F 4 >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
`<< /Length ${labels.length} >>\nstream\n${labels}\nendstream`,
"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Approval initials) /Rect [45 60 107 92] /P 3 0 R /F 4 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [11 0 R] /Resources << /Font << /F 6 0 R >> >> /Contents 10 0 R >>",
`<< /Length ${lines.length} >>\nstream\n${lines}\nendstream`,
"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Witness signature) /Rect [30 65 170 105] /P 9 0 R /F 4 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Resources << /Font << /F 6 0 R >> >> /Contents 13 0 R >>",
`<< /Length ${prose.length} >>\nstream\n${prose}\nendstream`,
]);
await app.evaluate(({ BrowserWindow }, data) => BrowserWindow.getAllWindows()[0].webContents.send(
"folio:open-file", { name: "Signature fields.pdf", data: Buffer.from(data) }), form);
const hint = page.getByRole("region", { name: "Signature suggestion" });
const field = page.getByRole("button", { name: "Sign Applicant signature", exact: true });
const initials = page.getByRole("button", { name: "Sign Approval initials", exact: true });
const fieldPicker = page.getByRole("dialog", { name: "Sign Applicant signature", exact: true });
async function clickAtPageFraction(button, x, y) {
await button.scrollIntoViewIfNeeded();
const position = await button.evaluate((el, point) => {
const paper = el.closest(".page").querySelector(".canvasWrapper").getBoundingClientRect();
const bounds = el.getBoundingClientRect();
return { x: paper.x + point.x * paper.width - bounds.x - el.clientLeft,
y: paper.y + point.y * paper.height - bounds.y - el.clientTop };
}, { x, y });
await button.click({ position });
}
await field.waitFor();
await page.getByRole("button", { name: "Edit mode", exact: true }).click();
await field.waitFor({ state: "hidden" });
await page.getByRole("button", { name: "Sign", exact: true }).click();
await page.getByRole("dialog", { name: "Sign page 1", exact: true }).getByRole("button", { name: "Cancel" }).click();
await field.waitFor();
const saved = await page.evaluate(() => {
const entries = Object.entries(localStorage).filter(([key]) => key === "folio:signature" || key.startsWith("folio:signature:"));
for (const [key] of entries) localStorage.removeItem(key);
return entries;
});
await initials.click();
const capture = page.getByRole("dialog", { name: "Sign page 1", exact: true });
const image = JSON.parse(saved[0][1]).url.split(",")[1];
await capture.getByLabel("Upload signature photo").setInputFiles({ name: "first.png", mimeType: "image/png", buffer: Buffer.from(image, "base64") });
assert.equal(await capture.getByRole("combobox", { name: "Signature placement" }).locator("option:checked").innerText(), "Approval initials");
await capture.getByRole("button", { name: "Cancel" }).click();
await page.evaluate(entries => { for (const [key, value] of entries) localStorage.setItem(key, value); }, saved);
await page.getByTitle(/^Zoom in/).click();
// Hit the PDF field's real center after zooming a rotated page with an offset crop box.
await clickAtPageFraction(field, (132.5 - 30) / 300, (130 - 20) / 300);
await fieldPicker.getByRole("button", { name: "Full name", exact: true }).waitFor();
await page.keyboard.press("Escape");
await fieldPicker.waitFor({ state: "hidden" });
assert.equal(await page.locator(".stampEditor").count(), 0);
await field.press("Enter");
await fieldPicker.getByRole("button", { name: "Full name", exact: true }).waitFor();
await page.keyboard.press("ControlOrMeta+=");
await fieldPicker.getByRole("button", { name: "Full name", exact: true }).click();
await field.waitFor({ state: "hidden" });
await initials.click();
await page.getByRole("dialog", { name: "Sign Approval initials" }).getByRole("button", { name: "Signature 1", exact: true }).click();
await initials.waitFor({ state: "hidden" });
assert.equal(await page.getByRole("dialog", { name: "Sign page 1" }).count(), 0);
// Two saved marks can share a name, so the shortcut shows each mark's image.
const other = saved.map(([key, value]) => ({ key, ...JSON.parse(value) }))
.find(mark => mark.key === "folio:signature" || mark.name !== "Full name");
await page.evaluate(mark => localStorage.setItem("folio:signature:same-name-check", JSON.stringify(mark)),
{ url: other.url, width: other.width, height: other.height, name: "Full name" });
await page.keyboard.press("ControlOrMeta+z");
await initials.waitFor();
const sameName = hint.getByRole("button", { name: "Sign with Full name", exact: true });
await sameName.first().waitFor();
const shown = await sameName.locator("img").evaluateAll(images => images.map(image => image.src));
assert.equal(shown.length, 2);
assert.notEqual(shown[0], shown[1], "marks that share a name show different images");
assert.ok(shown.includes(other.url));
await hint.getByRole("button", { name: "Sign with Signature 1", exact: true }).waitFor();
await sameName.nth(shown.indexOf(other.url)).click();
await initials.waitFor({ state: "hidden" });
await hint.waitFor({ state: "hidden" });
await page.evaluate(() => localStorage.removeItem("folio:signature:same-name-check"));
await page.keyboard.press("ControlOrMeta+z");
await initials.waitFor();
await hint.getByRole("button", { name: "Sign with Signature 1", exact: true }).waitFor();
await page.keyboard.press("ControlOrMeta+Comma");
await page.getByRole("dialog", { name: "Settings" }).getByRole("button", { name: "Storage", exact: true }).click();
await page.getByRole("button", { name: "Forget Full name", exact: true }).click();
await page.keyboard.press("Escape");
await hint.getByRole("button", { name: "Sign with Signature 1" }).click();
await hint.waitFor({ state: "hidden" });
console.log("ok native signature fields support capture, keyboard selection, zoom and undo");

await page.getByTitle("Next page").click();
await hint.getByRole("button", { name: "Review signature" }).click();
const placement = page.getByRole("combobox", { name: "Signature placement" });
assert.match(await placement.locator("option:checked").innerText(), /Approved by/);
const approve = page.getByRole("button", { name: "Approve and add signature" });
await approve.click();
await page.getByRole("dialog", { name: "Sign page 2" }).waitFor({ state: "hidden" });
const inferred = page.locator('.page[data-page-number="2"]').getByRole("button", { name: "Sign Initials", exact: true });
await inferred.click();
await page.getByRole("dialog", { name: "Sign Initials", exact: true }).getByRole("button", { name: "Signature 1", exact: true }).click();
await inferred.waitFor({ state: "hidden" });
await hint.waitFor({ state: "hidden" });
await page.evaluate(entries => { for (const [key, value] of entries) localStorage.setItem(key, value); }, saved);
await page.getByTitle("Next page").click();
const thirdPage = page.locator('.page[data-page-number="3"]');
const printed = thirdPage.getByRole("button", { name: "Sign Signature", exact: true });
const printedInitials = thirdPage.getByRole("button", { name: "Sign Initials", exact: true });
await printed.waitFor();
assert.equal(await thirdPage.locator(".signature-hit").count(), 3, "two printed lines and one native field");
// Click the ink itself, first on the drawn rule, then on underscores beside the initials label.
await clickAtPageFraction(printed, 130 / 300, 1 - 225 / 300);
await page.getByRole("dialog", { name: "Sign Signature", exact: true }).getByRole("button", { name: "Full name", exact: true }).click();
await printed.waitFor({ state: "hidden" });
await clickAtPageFraction(printedInitials, 105 / 300, 1 - 139 / 300);
await page.getByRole("dialog", { name: "Sign Initials", exact: true }).getByRole("button", { name: "Signature 1", exact: true }).click();
await printedInitials.waitFor({ state: "hidden" });
await thirdPage.locator(".stampEditor canvas").nth(1).waitFor();
assert.equal(await page.getByRole("dialog", { name: "Sign page 3", exact: true }).count(), 0);
await page.keyboard.press("ControlOrMeta+z");
await printedInitials.waitFor();
await page.keyboard.press("ControlOrMeta+Shift+z");
await thirdPage.locator(".stampEditor canvas").nth(1).waitFor();
await printedInitials.waitFor({ state: "hidden" });
// A sentence that mentions signing is not a place to sign; the captioned rule on the same page is.
await page.getByTitle("Next page").click();
const fourthPage = page.locator('.page[data-page-number="4"]');
await fourthPage.getByRole("button", { name: "Sign Witness signature", exact: true }).waitFor();
assert.equal(await fourthPage.locator(".signature-hit").count(), 1);
await page.getByRole("button", { name: "Sign", exact: true }).click();
const fourthDialog = page.getByRole("dialog", { name: "Sign page 4", exact: true });
// With two saved marks nothing is preselected, and the placement list appears once a mark is chosen.
await fourthDialog.getByRole("combobox", { name: "Saved signature" }).selectOption({ label: "Signature 1" });
// The page's places to sign reach the dialog shortly after the page turn.
const placements = fourthDialog.getByRole("combobox", { name: "Signature placement" }).locator("option");
await placements.filter({ hasText: "Witness signature" }).waitFor({ state: "attached" });
assert.deepEqual(await placements.allTextContents(), ["Witness signature", "Choose a position on this page"]);
await fourthDialog.getByRole("button", { name: "Cancel" }).click();
await fourthDialog.waitFor({ state: "hidden" });
assert.equal(await hint.count(), 0, "contract prose is not suggested as a signing area");
const file = path.join(folder, "signed-fields.pdf");
await captureDownload(app, file);
await page.keyboard.press("ControlOrMeta+s");
assert.equal(await app.evaluate(() => globalThis.folioReordered), "completed");
assert.equal(readFileSync(file, "latin1").match(/\/Subtype\s*\/Stamp\b/g)?.length, 6);
const stampCenters = await page.evaluate(async bytes => {
const task = pdfjsLib.getDocument({ data: Uint8Array.from(bytes) });
try {
const pdf = await task.promise;
return await Promise.all([1, 3].map(async number => {
const annotations = await (await pdf.getPage(number)).getAnnotations();
return annotations.filter(a => a.subtype === "Stamp").map(({ rect: [x1, y1, x2, y2] }) =>
[Math.round((x1 + x2) / 2), Math.round((y1 + y2) / 2)]);
}));
} finally { await task.destroy(); }
}, Array.from(readFileSync(file)));
const [nativeCenters, printedCenters] = stampCenters;
assert.equal(nativeCenters.length, 2);
for (const [x, y] of [[130, 132.5], [76, 76]]) {
assert.ok(nativeCenters.some(([cx, cy]) => Math.abs(cx - x) < 2 && Math.abs(cy - y) < 2), JSON.stringify(nativeCenters));
}
assert.equal(printedCenters.length, 2);
assert.ok(printedCenters.some(([x, y]) => Math.abs(x - 130) < 2 && Math.abs(y - 245) < 2), JSON.stringify(printedCenters));
assert.ok(printedCenters.some(([x, y]) => x > 98 && x < 113 && Math.abs(y - 154) < 2), JSON.stringify(printedCenters));
console.log("ok on-page signing finds native fields, printed rules, underscores and standalone captions, ignores sentences that mention signing, and exports marks at the chosen positions");
}

async function checkPenDrawing(app, page, folder, shots) {
const blank = testPdf(["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Count 1 /Kids [3 0 R] >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 480 320] /Resources << >> >>"]);
Expand Down Expand Up @@ -1056,6 +1239,7 @@ export async function checkReaderTools(app, page, folder, shots) {
await page.getByTitle("Next page").click();
assert.equal(await page.locator('.page[data-page-number="2"] .textWidgetAnnotation input').inputValue(), "A filled field");
console.log("ok the normal Save shortcut and Open reordered copy preserve filled form fields");
await checkSignatureFields(app, page, folder);
await checkScanSelection(app, page);
await checkContentEditing(app, page, folder, shots);
await checkImageInsertion(app, page, folder, shots);
Expand Down
4 changes: 2 additions & 2 deletions scripts/smoke-signature-targets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ export async function checkSignatureTargets(app, page, photo, shots) {
await page.locator(".textLayer span:not(.markedContent)").first().waitFor();
await page.getByTitle("Add a signature").click();
await page.getByLabel("Upload signature photo").setInputFiles({ name: "mark.png", mimeType: "image/png", buffer: Buffer.from(photo, "base64") });
await page.getByRole("button", { name: /Use this signature/ }).click();
await page.getByRole("button", { name: "Adjust crop and ink" }).waitFor();
const placement = page.getByLabel("Signature placement", { exact: true });
await placement.locator("option").filter({ hasText: "Witness signature" }).waitFor({ state: "attached" });
await placement.selectOption({ index: 0 });
const options = await placement.locator("option").allTextContents();
await page.screenshot({ path: path.join(shots, "signature-targets.png") });
assert.deepEqual(options, ["Witness signature: __________ (suggested)", "Choose a position on this page"],
assert.deepEqual(options, ["Witness signature", "Choose a position on this page"],
"only a standalone signing caption is suggested, never signature-related prose");
await page.getByRole("button", { name: "Cancel", exact: true }).click();
console.log("ok signature detection rejects contract prose and keeps standalone captions");
Expand Down
Loading
Loading