feat(pdf-editor): the dock opens the editor, and the editor offers more than one way in

Clicking the dock raised a native file browser, which was the right answer
while the editor had exactly one thing to offer an empty tab. It is the
wrong answer now: a file browser can only ask which PDF, and the answer is
sometimes none of them.

So the dock opens the editor, and the empty editor says what it can do.
The drop zone stays, and learns to read what it is given — pictures become
pages, several PDFs become one document. Beside it sit the three ways in
as buttons.

Not included: compress, which cannot be done honestly without re-encoding
the images, and split, which is the page rail plus Save a copy.

A document built from pictures or joins has never been on disk, so it is
marked unsaved from the moment it opens — otherwise closing the tab would
bin it without asking. An empty editor also stops claiming to hold a file
called document.pdf.
This commit is contained in:
Local Dev 2026-09-23 00:51:45 +02:00
parent c0436e0190
commit acc433805e
7 changed files with 333 additions and 54 deletions

View file

@ -1,8 +1,8 @@
{
"id": "pdf-editor",
"name": "PDF Editor",
"version": "0.3.1",
"description": "Open a PDF in a full tab: read it, mark it up with highlight, underline, strike-through, pen, shapes, arrows, text and a drawn signature, then move, resize, restyle or delete anything you placed. Reorder, rotate or drop pages, fill in form fields, redact for real, and save a new copy. Marks are written into the saved file as real PDF content. The original is never touched.",
"version": "0.4.0",
"description": "Open a PDF in a full tab: read it, mark it up with highlight, underline, strike-through, pen, shapes, arrows, text and a drawn signature, then move, resize, restyle or delete anything you placed. Reorder, rotate or drop pages, fill in form fields, redact for real, and save a new copy. Build a PDF from pictures, one page each, or join several PDFs into one. Marks are written into the saved file as real PDF content. The original is never touched.",
"author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",
"main": "index.js",
@ -11,7 +11,7 @@
"title": "PDF Editor",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",
"items": [
{ "id": "open", "label": "Open a PDF…" }
{ "id": "open", "label": "Open the editor" }
]
},
"context-menu-items": [

View file

@ -139,7 +139,24 @@ body[data-dragover="1"] .dropzone { display: flex; pointer-events: none;
body[data-dragover="1"] .dz-inner { border-color: rgb(from var(--acid) r g b / .8); color: var(--ink); }
.dz-inner svg { width: 48px; height: 48px; margin-bottom: 10px; }
.dz-title { font-size: 15px; color: var(--ink); font-weight: 600; }
.dz-sub { margin-top: 5px; }
.dz-sub { margin-top: 5px; max-width: 42ch; margin-left: auto; margin-right: auto; }
/* Start-screen tiles. Hidden while a drag is in flight: during a drop the
drop zone is the whole target, and a row of buttons under the cursor
reads as somewhere to aim, which it is not. */
.dz-actions { display: flex; gap: 10px; margin-top: 22px; justify-content: center;
flex-wrap: wrap; }
body[data-dragover="1"] .dz-actions { visibility: hidden; }
.tile { display: flex; flex-direction: column; align-items: flex-start; gap: 3px;
width: 176px; padding: 13px 14px; text-align: left; cursor: pointer;
background: var(--bg); color: var(--ink); border: 1px solid var(--line);
border-radius: 10px; font: inherit; }
.tile:hover { border-color: rgb(from var(--acid) r g b / .55);
background: rgb(from var(--acid) r g b / .06); }
.tile:focus-visible { outline: 2px solid var(--acid); outline-offset: 2px; }
.tile svg { width: 22px; height: 22px; margin: 0 0 5px; color: var(--acid); }
.tile-t { font-weight: 600; font-size: 13px; }
.tile-s { font-size: 11.5px; color: var(--dim); line-height: 1.35; }
.linkish { background: none; border: none; padding: 0; font: inherit; cursor: pointer;
color: var(--acid); text-decoration: underline; }

View file

@ -141,7 +141,31 @@
<path d="M12 6h16l8 8v28H12z"/><path d="M28 6v8h8"/><path d="M24 22v12M19 29l5 5 5-5"/>
</svg>
<div class="dz-title">Drop a PDF here</div>
<div class="dz-sub">or <button class="linkish" id="open2">choose a file</button></div>
<div class="dz-sub">Drop pictures instead and they become the pages. Drop several PDFs and they join up.</div>
<div class="dz-actions">
<button class="tile" id="start-open" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4"/><path d="M9 12h6M9 16h6"/>
</svg>
<span class="tile-t">Open a PDF</span>
<span class="tile-s">Read it, mark it up, fill it in, sign it</span>
</button>
<button class="tile" id="start-images" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="5" width="14" height="11" rx="1.5"/><circle cx="7.5" cy="9" r="1.3"/>
<path d="M3.5 14l3.5-3 3 2.5 2.5-2 4 4.5"/><path d="M19 9v10H8"/>
</svg>
<span class="tile-t">Pictures to PDF</span>
<span class="tile-s">One page per picture, in the order you pick</span>
</button>
<button class="tile" id="start-merge" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4h7v7H4z"/><path d="M13 13h7v7h-7z"/><path d="M11 7.5h4a2 2 0 0 1 2 2V13"/><path d="M15 11l2 2 2-2"/>
</svg>
<span class="tile-t">Join PDFs</span>
<span class="tile-s">Several files, end to end, as one document</span>
</button>
</div>
</div>
</div>
</div>
@ -232,6 +256,33 @@
</div>
</div>
<div class="modal" id="modal-images" hidden>
<h2>Pictures to PDF</h2>
<p id="images-count"></p>
<div class="mrow">
<label class="mfield">Page
<select id="images-page">
<option value="fit" selected>Shaped like the picture</option>
<option value="a4">A4</option>
<option value="letter">Letter</option>
</select>
</label>
<label class="mfield" id="images-margin-field">Border
<select id="images-margin">
<option value="0">None</option>
<option value="18" selected>Narrow</option>
<option value="48">Wide</option>
</select>
</label>
</div>
<p class="mhint" id="images-note"></p>
<div class="mrow">
<span class="spacer"></span>
<button class="btn wide" id="images-cancel">Cancel</button>
<button class="btn wide primary" id="images-go">Make the PDF</button>
</div>
</div>
<div class="modal" id="modal-save" hidden>
<h2>Save a copy</h2>
<label class="mfield block">File name
@ -276,6 +327,8 @@
<div class="toast" id="toast"></div>
<input type="file" id="file-input" accept="application/pdf,.pdf" hidden>
<input type="file" id="img-input" accept="image/*" multiple hidden>
<input type="file" id="merge-input" accept="application/pdf,.pdf" multiple hidden>
<a id="download-link" style="display:none"></a>
<script type="module" src="editor.js"></script>

View file

@ -23,6 +23,7 @@ import { buildPdf, unencodableStamps, replacedRuns } from "./lib/save.js";
import { boundsOf, clampFont } from "./lib/shape.js";
import { reflow } from "./lib/reflow.js";
import { buildDocx } from "./lib/docx.js";
import { imagesToPdf, mergePdfs } from "./lib/create.js";
const $ = (id) => document.getElementById(id);
const sm = window.silentmode || null;
@ -60,7 +61,7 @@ const session = {
rail: null,
tools: null,
scratchId: null, // set when the document came from the add-on's scratch dir
name: "document.pdf",
name: null, // null until something is loaded; the chrome says so
};
// ---- chrome -----------------------------------------------------------
@ -264,10 +265,15 @@ async function teardown() {
}
// ---- open paths -------------------------------------------------------
// A tab that can be given files three ways — the dock, a button, a drop — has
// to decide what a handful of files MEANS before it can act. One PDF is a
// document to edit. Several are a document to build. Pictures are pages.
const isPdfFile = (f) => /\.pdf$/i.test(f.name) || f.type === "application/pdf";
const isImageFile = (f) => /^image\//.test(f.type) || /\.(png|jpe?g|webp|gif|bmp|avif|tiff?)$/i.test(f.name);
async function openFile(file) {
if (!file) return;
const looksPdf = /\.pdf$/i.test(file.name) || file.type === "application/pdf";
if (!looksPdf) { toast(`${file.name} is not a PDF`, true); return; }
if (!isPdfFile(file)) { toast(`${file.name} is not a PDF`, true); return; }
if (!(await confirmDiscard())) return;
const buf = new Uint8Array(await file.arrayBuffer());
if (buf.subarray(0, 5).every((b, i) => b === "%PDF-".charCodeAt(i)) === false) {
@ -277,14 +283,114 @@ async function openFile(file) {
await loadBytes(buf, file.name);
}
async function openFiles(list) {
const files = [...(list || [])];
if (!files.length) return;
if (files.length === 1 && isPdfFile(files[0])) return openFile(files[0]);
if (files.every(isImageFile)) return fromImages(files);
if (files.every(isPdfFile)) return joinPdfs(files);
const odd = files.find((f) => !isPdfFile(f) && !isImageFile(f));
toast(odd ? `${odd.name} is neither a PDF nor a picture` : "Drop PDFs or pictures, not both at once", true);
}
/**
* Pictures in, one page each.
*
* The result is a document that exists only here until it is saved, so it is
* marked unsaved the moment it opens — otherwise closing the tab would throw
* away work that never touched a disk, without asking.
*/
async function fromImages(files) {
if (!(await confirmDiscard())) return;
$("images-count").textContent =
`${files.length} picture${files.length === 1 ? "" : "s"}, one per page, in the order your file browser listed them.`;
syncImagesDialog();
if (!(await openModal("modal-images"))) return;
const page = $("images-page").value;
const margin = Number($("images-margin").value) || 0;
setBusy(true, "Building…");
let out;
try {
out = await imagesToPdf(files, {
page, margin,
onProgress: (i, n) => setBusy(true, `Adding picture ${i} of ${n}…`),
});
} catch (e) {
setBusy(false);
status(`Could not build a PDF: ${e?.message || e}`, true);
toast(`Could not build a PDF: ${e?.message || e}`, true);
return;
}
if (!(await loadBytes(new Uint8Array(out.bytes), "pictures.pdf"))) return;
afterBuild(`${out.pages} page${out.pages === 1 ? "" : "s"} from your pictures`, out.failed);
}
/**
* Several PDFs, end to end. Order is the order they were handed over; the
* page rail is where it gets fixed, which is the honest answer to "can I
* reorder them" — yes, once they are one document.
*/
async function joinPdfs(files) {
if (!(await confirmDiscard())) return;
setBusy(true, "Joining…");
let out;
try {
out = await mergePdfs(files, { onProgress: (i, n) => setBusy(true, `Reading file ${i} of ${n}…`) });
} catch (e) {
setBusy(false);
status(`Could not join those files: ${e?.message || e}`, true);
toast(`Could not join those files: ${e?.message || e}`, true);
return;
}
if (!(await loadBytes(new Uint8Array(out.bytes), "joined.pdf"))) return;
const from = out.parts.map((p) => `${p.name} (${p.pages})`).join(", ");
afterBuild(`${out.pages} pages from ${out.parts.length} files · ${from}`, out.failed);
if (out.hadForms) {
toast("Form fields do not survive joining — the pages kept their look, not their fields", true);
}
}
// Shared tail for the two build paths: say where the document came from, say
// that it is not on disk yet, and name anything that had to be skipped.
function afterBuild(what, failed) {
session.model.markUnsaved();
paintChrome();
status(`${what} · not saved yet — use Save a copy`);
if (failed && failed.length) {
toast(`Skipped ${failed.length} file${failed.length === 1 ? "" : "s"}: ${failed.map((f) => f.name).join(", ")}`, true);
}
}
// A page shaped like its picture has no border to set, so the field goes away
// rather than sitting there doing nothing.
function syncImagesDialog() {
const fit = $("images-page").value === "fit";
$("images-margin-field").hidden = fit;
$("images-note").textContent = fit
? "Each page takes the shape of its picture, edge to edge, sized so the long side matches A4."
: "Each picture is centred on the page, scaled to fit inside the border. Wide pictures turn the page on its side.";
}
$("images-page").addEventListener("change", syncImagesDialog);
$("images-go").addEventListener("click", () => closeModal(true));
$("images-cancel").addEventListener("click", () => closeModal(null));
$("file-input").addEventListener("change", (e) => {
const f = e.target.files?.[0];
e.target.value = "";
openFile(f);
});
for (const [inputId, handler] of [["img-input", fromImages], ["merge-input", joinPdfs]]) {
$(inputId).addEventListener("change", (e) => {
const files = [...(e.target.files || [])];
e.target.value = "";
if (files.length) handler(files);
});
}
const pick = () => $("file-input").click();
$("open").addEventListener("click", pick);
$("open2").addEventListener("click", pick);
$("start-open").addEventListener("click", pick);
$("start-images").addEventListener("click", () => $("img-input").click());
$("start-merge").addEventListener("click", () => $("merge-input").click());
// Drag and drop anywhere in the tab.
for (const type of ["dragenter", "dragover"]) {
@ -303,7 +409,7 @@ window.addEventListener("drop", (e) => {
if (![...(e.dataTransfer?.types || [])].includes("Files")) return;
e.preventDefault();
document.body.dataset.dragover = "";
openFile(e.dataTransfer.files?.[0]);
openFiles(e.dataTransfer.files);
});
// ---- page operations --------------------------------------------------
@ -1101,7 +1207,7 @@ window.addEventListener("keydown", (e) => {
} catch {}
const id = new URLSearchParams(location.search).get("doc");
if (!id) { status("Drop a PDF here, or use Open."); paintChrome(); return; }
if (!id) { status("Drop a PDF or some pictures here, or pick one of the options."); paintChrome(); return; }
if (!sm?.invoke) { status("This page has to be opened from the PDF Editor add-on.", true); return; }
setBusy(true, "Fetching…");
try {

View file

@ -97,55 +97,21 @@ module.exports = {
// ---- launch paths -------------------------------------------------
// Dock → the file picker, straight away.
//
// This used to open an empty editor whose own drop zone then asked for a
// file, which meant three clicks and two screens before the user reached
// their own documents: dock, menu item, Open, and only then the file
// browser. Asking here collapses it — the dock click IS the "open a file"
// gesture, so answer it with a file browser.
// The dock click opens the editor, and stops there.
//
// The dialog is native, raised from the add-on's Node side, because a
// file:// page cannot open one without a user gesture of its own and a
// freshly-opened tab has no gesture to spend.
//
// Cancelling still lands you in the editor, empty. Someone who changed
// their mind about which file probably still wants the editor, and it is
// where drag-and-drop works.
// It briefly raised a native file browser instead, to save the clicks
// between the dock and a document. That was right while the editor had
// exactly one thing to offer an empty tab. It stopped being right once
// the start screen grew: a file browser can only answer "which PDF",
// and the answer is now sometimes "none of them — build one from these
// photos" or "weld these four together".
api.onMessage("menu-select", async (payload) => {
const item = String(payload && payload.id || "");
if (item !== "open") throw new Error(`unknown menu item: ${item}`);
const entry = await pickPdf();
if (!entry) { api.openTab("editor.html"); api.log("picker cancelled — opened an empty editor"); return { ok: true, cancelled: true }; }
api.openTab("editor.html", { query: { doc: entry.id } });
api.log(`picked ${entry.name} (${entry.size} bytes) → ${entry.id}`);
return { ok: true, id: entry.id };
api.openTab("editor.html");
return { ok: true };
});
/**
* Native open dialog, then the bytes in scratch. Returns null when the
* user cancels or picks something that is not a PDF.
*/
async function pickPdf() {
const { dialog, BrowserWindow } = api.require("electron");
// Parent it to Theseus so the dialog is attached to the window rather
// than floating loose behind it.
const parent = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0] || null;
const opts = {
title: "Open a PDF",
properties: ["openFile"],
filters: [{ name: "PDF documents", extensions: ["pdf"] }, { name: "All files", extensions: ["*"] }],
};
const res = parent ? await dialog.showOpenDialog(parent, opts) : await dialog.showOpenDialog(opts);
const file = !res.canceled && res.filePaths && res.filePaths[0];
if (!file) return null;
const stat = await fs.promises.stat(file);
if (stat.size > MAX_BYTES) throw new Error(`${path.basename(file)} is ${(stat.size / 1048576).toFixed(0)} MB — over the ${MAX_BYTES / 1048576} MB cap`);
const buf = await fs.promises.readFile(file);
if (buf.subarray(0, 5).toString("latin1") !== "%PDF-") {
throw new Error(`${path.basename(file)} does not start with a PDF header`);
}
return writeScratch(buf, safeName(path.basename(file)));
}
// Right-click a link → fetch it here, hand the editor an id.
api.onMessage("context-menu", async (payload) => {
if (String(payload && payload.itemId || "") !== "open-link") return;

View file

@ -0,0 +1,133 @@
// Making a PDF where there wasn't one: photos in, or several files welded
// into one. Both paths hand back plain bytes, which the editor then opens as
// if they had come off disk — so everything downstream (marks, page rail,
// save, convert) works on them without knowing where they came from.
import { PDFDocument } from "../vendor/pdf-lib/pdf-lib.esm.min.js";
// Points, the PDF's own unit. A4 is the world's paper; Letter is North
// America's. "fit" below means neither: the page takes the image's shape.
export const PAGE_SIZES = {
a4: [595.28, 841.89],
letter: [612, 792],
};
// The long edge of a fitted page. A phone photo is 4000 px on its longest
// side; read as points that would be a 55-inch page, technically valid and
// useless to anyone who prints it. Mapping the long edge to A4's long edge
// keeps the image's proportions and gives a page a printer understands.
const FIT_LONG_EDGE = 841.89;
const JPEG = [0xff, 0xd8, 0xff];
const PNG = [0x89, 0x50, 0x4e, 0x47];
const starts = (bytes, sig) => sig.every((b, i) => bytes[i] === b);
/**
* pdf-lib embeds JPEG and PNG, and nothing else. Rather than refuse a WebP or
* a HEIC-turned-WebP screenshot, hand it to the browser — which can decode far
* more than pdf-lib can embed — and re-encode as PNG. Lossless, so a screenshot
* survives intact; a photo re-encoded this way grows, which is why anything
* already JPEG is passed through untouched.
*/
async function toEmbeddable(file) {
const bytes = new Uint8Array(await file.arrayBuffer());
if (starts(bytes, JPEG)) return { bytes, kind: "jpg" };
if (starts(bytes, PNG)) return { bytes, kind: "png" };
let bitmap;
try { bitmap = await createImageBitmap(file); }
catch { throw new Error(`${file.name} is not an image this browser can read`); }
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
canvas.getContext("2d").drawImage(bitmap, 0, 0);
bitmap.close();
const blob = await canvas.convertToBlob({ type: "image/png" });
return { bytes: new Uint8Array(await blob.arrayBuffer()), kind: "png" };
}
/**
* One image per page, in the order given.
*
* `page` is "fit" (the page takes each image's shape), "a4" or "letter". For
* the two paper sizes the sheet turns landscape when the image is wider than
* it is tall, because a landscape photo on a portrait page is mostly margin.
* `margin` is in points and ignored by "fit" — the whole point of fitting the
* page to the image is that there is no border.
*/
export async function imagesToPdf(files, { page = "fit", margin = 18, onProgress } = {}) {
if (!files.length) throw new Error("no images given");
const pdf = await PDFDocument.create();
const failed = [];
let n = 0;
for (const file of files) {
onProgress?.(++n, files.length, file.name);
let img;
try {
const { bytes, kind } = await toEmbeddable(file);
img = kind === "jpg" ? await pdf.embedJpg(bytes) : await pdf.embedPng(bytes);
} catch (e) {
failed.push({ name: file.name, why: e?.message || String(e) });
continue;
}
if (page === "fit") {
const scale = FIT_LONG_EDGE / Math.max(img.width, img.height);
const w = img.width * scale, h = img.height * scale;
pdf.addPage([w, h]).drawImage(img, { x: 0, y: 0, width: w, height: h });
continue;
}
const [pw, ph] = PAGE_SIZES[page] || PAGE_SIZES.a4;
const landscape = img.width > img.height;
const sheet = pdf.addPage(landscape ? [ph, pw] : [pw, ph]);
const boxW = sheet.getWidth() - margin * 2;
const boxH = sheet.getHeight() - margin * 2;
// contain, never crop: the smaller ratio wins, and the leftover becomes
// an even border on the two sides that did not bind.
const scale = Math.min(boxW / img.width, boxH / img.height);
const w = img.width * scale, h = img.height * scale;
sheet.drawImage(img, {
x: (sheet.getWidth() - w) / 2,
y: (sheet.getHeight() - h) / 2,
width: w, height: h,
});
}
if (!pdf.getPageCount()) {
throw new Error(failed.length ? `none of those files could be read (${failed[0].why})` : "no pages were made");
}
return { bytes: await pdf.save(), pages: pdf.getPageCount(), failed };
}
/**
* Several PDFs, end to end, in the order given.
*
* Page content, annotations and links inside each document survive, because
* copyPages carries the whole page object across. What does NOT survive is
* anything that lives above the page: form fields, the outline, and
* attachments. Those belong to the document they came from, and merging four
* documents that each define a field called "name" has no correct answer.
* The editor says so plainly rather than silently dropping them.
*/
export async function mergePdfs(files, { onProgress } = {}) {
if (files.length < 2) throw new Error("pick at least two PDFs to merge");
const out = await PDFDocument.create();
const parts = [];
const failed = [];
let hadForms = false;
let n = 0;
for (const file of files) {
onProgress?.(++n, files.length, file.name);
try {
const bytes = new Uint8Array(await file.arrayBuffer());
// An encrypted-but-openable PDF is the common case (print/copy flags,
// no password). ignoreEncryption lets those through; a real
// password-protected file still throws, and lands in `failed`.
const src = await PDFDocument.load(bytes, { ignoreEncryption: true });
try { if (src.getForm().getFields().length) hadForms = true; } catch {}
const copied = await out.copyPages(src, src.getPageIndices());
for (const p of copied) out.addPage(p);
parts.push({ name: file.name, pages: copied.length });
} catch (e) {
failed.push({ name: file.name, why: e?.message || String(e) });
}
}
if (!out.getPageCount()) {
throw new Error(failed.length ? `nothing could be read (${failed[0].why})` : "no pages were copied");
}
return { bytes: await out.save(), pages: out.getPageCount(), parts, failed, hadForms };
}

View file

@ -127,6 +127,10 @@ export class DocModel {
return step.label;
}
markSaved() { this.savedMark = this._key(); this._emit("saved"); }
// A document this session BUILT — from pictures, or by joining files — has
// never been anywhere. It carries no marks, so the usual comparison calls
// it clean and closing the tab would take it away without a word.
markUnsaved() { this.savedMark = null; this._emit("dirty"); }
isDirty() { return this._key() !== this.savedMark; }
// ---- mutations ----------------------------------------------------