web-doc 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/THIRD_PARTY_NOTICES.md +26 -1
- package/dist/adapters/docx-images.d.ts +13 -4
- package/dist/adapters/docx-images.js +3 -0
- package/dist/adapters/docx-paragraphs.d.ts +65 -0
- package/dist/adapters/docx-paragraphs.js +165 -0
- package/dist/adapters/docx-prepass.d.ts +17 -0
- package/dist/adapters/docx-prepass.js +230 -0
- package/dist/adapters/office.d.ts +31 -5
- package/dist/adapters/office.js +60 -29
- package/dist/adapters/pdf.d.ts +9 -0
- package/dist/adapters/pdf.js +10 -0
- package/dist/assets/pdfium/pdfium.wasm +0 -0
- package/dist/contracts.d.ts +47 -2
- package/dist/edit/assets.d.ts +23 -0
- package/dist/edit/assets.js +75 -0
- package/dist/edit/docx/elements.d.ts +6 -0
- package/dist/edit/docx/elements.js +90 -0
- package/dist/edit/docx/engine.d.ts +51 -0
- package/dist/edit/docx/engine.js +473 -0
- package/dist/edit/docx/handlers.d.ts +3 -0
- package/dist/edit/docx/handlers.js +15 -0
- package/dist/edit/docx/ids.d.ts +45 -0
- package/dist/edit/docx/ids.js +101 -0
- package/dist/edit/docx/model.d.ts +92 -0
- package/dist/edit/docx/model.js +339 -0
- package/dist/edit/docx/operations.d.ts +37 -0
- package/dist/edit/docx/operations.js +3 -0
- package/dist/edit/docx/provider.d.ts +13 -0
- package/dist/edit/docx/provider.js +34 -0
- package/dist/edit/docx/schemas.d.ts +6 -0
- package/dist/edit/docx/schemas.js +192 -0
- package/dist/edit/docx/session.d.ts +37 -0
- package/dist/edit/docx/session.js +426 -0
- package/dist/edit/docx/structure-ops.d.ts +6 -0
- package/dist/edit/docx/structure-ops.js +494 -0
- package/dist/edit/docx/style.d.ts +45 -0
- package/dist/edit/docx/style.js +375 -0
- package/dist/edit/docx/table-ops.d.ts +4 -0
- package/dist/edit/docx/table-ops.js +219 -0
- package/dist/edit/docx/text-ops.d.ts +18 -0
- package/dist/edit/docx/text-ops.js +458 -0
- package/dist/edit/docx/text.d.ts +34 -0
- package/dist/edit/docx/text.js +263 -0
- package/dist/edit/docx/types.d.ts +191 -0
- package/dist/edit/docx/types.js +1 -0
- package/dist/edit/docx/write.d.ts +50 -0
- package/dist/edit/docx/write.js +352 -0
- package/dist/edit/engine.d.ts +117 -0
- package/dist/edit/engine.js +1 -0
- package/dist/edit/history.d.ts +53 -0
- package/dist/edit/history.js +101 -0
- package/dist/edit/ooxml/names.d.ts +20 -0
- package/dist/edit/ooxml/names.js +61 -0
- package/dist/edit/ooxml/opc.d.ts +50 -0
- package/dist/edit/ooxml/opc.js +150 -0
- package/dist/edit/ooxml/package.d.ts +82 -0
- package/dist/edit/ooxml/package.js +233 -0
- package/dist/edit/ooxml/patch.d.ts +51 -0
- package/dist/edit/ooxml/patch.js +250 -0
- package/dist/edit/ooxml/transaction.d.ts +39 -0
- package/dist/edit/ooxml/transaction.js +316 -0
- package/dist/edit/ooxml/worker.d.ts +8 -0
- package/dist/edit/ooxml/worker.js +12 -0
- package/dist/edit/ooxml/writer.d.ts +21 -0
- package/dist/edit/ooxml/writer.js +187 -0
- package/dist/edit/ooxml/xml.d.ts +74 -0
- package/dist/edit/ooxml/xml.js +451 -0
- package/dist/edit/ooxml/zip.d.ts +54 -0
- package/dist/edit/ooxml/zip.js +280 -0
- package/dist/edit/operations.d.ts +19 -0
- package/dist/edit/operations.js +137 -0
- package/dist/edit/pdf/engine/compact.d.ts +6 -0
- package/dist/edit/pdf/engine/compact.js +442 -0
- package/dist/edit/pdf/engine/document.d.ts +95 -0
- package/dist/edit/pdf/engine/document.js +868 -0
- package/dist/edit/pdf/engine/elements.d.ts +45 -0
- package/dist/edit/pdf/engine/elements.js +313 -0
- package/dist/edit/pdf/engine/existing-text.d.ts +4 -0
- package/dist/edit/pdf/engine/existing-text.js +424 -0
- package/dist/edit/pdf/engine/fonts.d.ts +78 -0
- package/dist/edit/pdf/engine/fonts.js +466 -0
- package/dist/edit/pdf/engine/geometry.d.ts +36 -0
- package/dist/edit/pdf/engine/geometry.js +97 -0
- package/dist/edit/pdf/engine/handler.d.ts +21 -0
- package/dist/edit/pdf/engine/handler.js +108 -0
- package/dist/edit/pdf/engine/images.d.ts +33 -0
- package/dist/edit/pdf/engine/images.js +188 -0
- package/dist/edit/pdf/engine/layout.d.ts +23 -0
- package/dist/edit/pdf/engine/layout.js +177 -0
- package/dist/edit/pdf/engine/operations.d.ts +67 -0
- package/dist/edit/pdf/engine/operations.js +3 -0
- package/dist/edit/pdf/engine/pages.d.ts +6 -0
- package/dist/edit/pdf/engine/pages.js +98 -0
- package/dist/edit/pdf/engine/pdfium.d.ts +195 -0
- package/dist/edit/pdf/engine/pdfium.js +249 -0
- package/dist/edit/pdf/engine/shapes.d.ts +4 -0
- package/dist/edit/pdf/engine/shapes.js +183 -0
- package/dist/edit/pdf/engine/tables.d.ts +44 -0
- package/dist/edit/pdf/engine/tables.js +315 -0
- package/dist/edit/pdf/engine/text-box.d.ts +71 -0
- package/dist/edit/pdf/engine/text-box.js +317 -0
- package/dist/edit/pdf/engine/text-layout.d.ts +28 -0
- package/dist/edit/pdf/engine/text-layout.js +67 -0
- package/dist/edit/pdf/engine/transform.d.ts +5 -0
- package/dist/edit/pdf/engine/transform.js +137 -0
- package/dist/edit/pdf/provider.d.ts +35 -0
- package/dist/edit/pdf/provider.js +92 -0
- package/dist/edit/pdf/range-map.d.ts +14 -0
- package/dist/edit/pdf/range-map.js +107 -0
- package/dist/edit/pdf/schemas.d.ts +11 -0
- package/dist/edit/pdf/schemas.js +291 -0
- package/dist/edit/pdf/selection.d.ts +5 -0
- package/dist/edit/pdf/selection.js +152 -0
- package/dist/edit/pdf/session.d.ts +53 -0
- package/dist/edit/pdf/session.js +273 -0
- package/dist/edit/pdf/types.d.ts +336 -0
- package/dist/edit/pdf/types.js +1 -0
- package/dist/edit/pptx/elements.d.ts +74 -0
- package/dist/edit/pptx/elements.js +301 -0
- package/dist/edit/pptx/engine.d.ts +37 -0
- package/dist/edit/pptx/engine.js +466 -0
- package/dist/edit/pptx/geometry.d.ts +72 -0
- package/dist/edit/pptx/geometry.js +193 -0
- package/dist/edit/pptx/handler.d.ts +7 -0
- package/dist/edit/pptx/handler.js +91 -0
- package/dist/edit/pptx/handlers.d.ts +3 -0
- package/dist/edit/pptx/handlers.js +21 -0
- package/dist/edit/pptx/image-table-ops.d.ts +5 -0
- package/dist/edit/pptx/image-table-ops.js +233 -0
- package/dist/edit/pptx/model.d.ts +69 -0
- package/dist/edit/pptx/model.js +170 -0
- package/dist/edit/pptx/operations.d.ts +50 -0
- package/dist/edit/pptx/operations.js +11 -0
- package/dist/edit/pptx/provider.d.ts +17 -0
- package/dist/edit/pptx/provider.js +40 -0
- package/dist/edit/pptx/schemas.d.ts +6 -0
- package/dist/edit/pptx/schemas.js +220 -0
- package/dist/edit/pptx/session.d.ts +44 -0
- package/dist/edit/pptx/session.js +114 -0
- package/dist/edit/pptx/shape-ops.d.ts +10 -0
- package/dist/edit/pptx/shape-ops.js +486 -0
- package/dist/edit/pptx/slide-ops.d.ts +6 -0
- package/dist/edit/pptx/slide-ops.js +353 -0
- package/dist/edit/pptx/style.d.ts +31 -0
- package/dist/edit/pptx/style.js +181 -0
- package/dist/edit/pptx/text-ops.d.ts +13 -0
- package/dist/edit/pptx/text-ops.js +267 -0
- package/dist/edit/pptx/text-write.d.ts +67 -0
- package/dist/edit/pptx/text-write.js +293 -0
- package/dist/edit/pptx/text.d.ts +41 -0
- package/dist/edit/pptx/text.js +99 -0
- package/dist/edit/pptx/types.d.ts +232 -0
- package/dist/edit/pptx/types.js +1 -0
- package/dist/edit/schema.d.ts +11 -0
- package/dist/edit/schema.js +275 -0
- package/dist/edit/session.d.ts +66 -0
- package/dist/edit/session.js +701 -0
- package/dist/edit/sessions.d.ts +8 -0
- package/dist/edit/sessions.js +1 -0
- package/dist/edit/types.d.ts +276 -0
- package/dist/edit/types.js +1 -0
- package/dist/edit/worker-engine.d.ts +31 -0
- package/dist/edit/worker-engine.js +91 -0
- package/dist/fonts/THIRD_PARTY_NOTICES.md +3 -0
- package/dist/fonts/manifest.json +6 -1
- package/dist/fonts/noto-sans-latin-cyrillic.ttf +0 -0
- package/dist/fonts.d.ts +2 -0
- package/dist/fonts.js +4 -0
- package/dist/headless.d.ts +4 -0
- package/dist/headless.js +4 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/limits.js +3 -0
- package/dist/ooxml-edit-worker.d.ts +1 -0
- package/dist/ooxml-edit-worker.js +8 -0
- package/dist/pdf-edit-worker.d.ts +1 -0
- package/dist/pdf-edit-worker.js +35 -0
- package/dist/spreadsheet-viewport.d.ts +5 -0
- package/dist/spreadsheet-viewport.js +11 -0
- package/dist/ui.js +7 -0
- package/dist/viewer.d.ts +6 -0
- package/dist/viewer.js +293 -19
- package/dist/viewport.d.ts +13 -0
- package/dist/viewport.js +126 -1
- package/dist/worker-protocol.d.ts +39 -2
- package/dist/workers/ooxml-edit-worker.js +8671 -0
- package/dist/workers/pdf-edit-worker.js +10844 -0
- package/package.json +3 -3
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
import { ViewerError } from "../../../errors.js";
|
|
2
|
+
import { invalidOperationError, parseReference } from "../../operations.js";
|
|
3
|
+
import { layoutOf, layoutsOf, positionIn, rectsOf, } from "./layout.js";
|
|
4
|
+
import { markIsFresh, readMark, scanPage, } from "./elements.js";
|
|
5
|
+
import { FontLibrary, TextMeasurer } from "./fonts.js";
|
|
6
|
+
import { fontRequestsOf } from "./text-box.js";
|
|
7
|
+
import { issueCollector, } from "./operations.js";
|
|
8
|
+
import { insertTextBox } from "./text-box.js";
|
|
9
|
+
import { replaceText, setTextStyle } from "./existing-text.js";
|
|
10
|
+
import { deleteElement, moveElement, resizeElement } from "./transform.js";
|
|
11
|
+
import { deletePage, insertPage, movePage, rotatePage } from "./pages.js";
|
|
12
|
+
import { insertShape, setShapeStyle } from "./shapes.js";
|
|
13
|
+
import { compactPdf, PdfCompactionError } from "./compact.js";
|
|
14
|
+
import { ImageCache, insertImage } from "./images.js";
|
|
15
|
+
import { AssetStore } from "../../assets.js";
|
|
16
|
+
import { insertTable, setTableCell, tableSpecOf } from "./tables.js";
|
|
17
|
+
import { defaultResourceLimits } from "../../../limits.js";
|
|
18
|
+
import { displayedSize, rectContains, rectsIntersect, round, roundRect, unionRects, userRectToPage, } from "./geometry.js";
|
|
19
|
+
/**
|
|
20
|
+
* Looks for a DocMDP certification, a tagged structure and a PDF/A claim.
|
|
21
|
+
* Signature dictionaries and XMP metadata are stored uncompressed, so the
|
|
22
|
+
* raw bytes answer for those; tagging also shows as a structure tree.
|
|
23
|
+
*/
|
|
24
|
+
/** FPDFBitmap_CreateEx pixel format with alpha. */
|
|
25
|
+
const BITMAP_BGRA = 4;
|
|
26
|
+
/** FPDF_RenderPageBitmap flag: draw annotations, as PDF.js does. */
|
|
27
|
+
const RENDER_ANNOTATIONS = 0x01;
|
|
28
|
+
function detectFeatures(pdfium, document, original) {
|
|
29
|
+
const features = [];
|
|
30
|
+
if (containsAscii(original, "/DocMDP"))
|
|
31
|
+
features.push("docmdp");
|
|
32
|
+
const { lib } = pdfium;
|
|
33
|
+
let tagged = containsAscii(original, "/Marked true");
|
|
34
|
+
if (!tagged && lib.FPDF_GetPageCount(document) > 0) {
|
|
35
|
+
const page = lib.FPDF_LoadPage(document, 0);
|
|
36
|
+
if (page) {
|
|
37
|
+
const tree = lib.FPDF_StructTree_GetForPage(page);
|
|
38
|
+
if (tree) {
|
|
39
|
+
tagged = lib.FPDF_StructTree_CountChildren(tree) > 0;
|
|
40
|
+
lib.FPDF_StructTree_Close(tree);
|
|
41
|
+
}
|
|
42
|
+
lib.FPDF_ClosePage(page);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (tagged)
|
|
46
|
+
features.push("tagged");
|
|
47
|
+
if (containsAscii(original, "pdfaid:part"))
|
|
48
|
+
features.push("pdfa");
|
|
49
|
+
return features;
|
|
50
|
+
}
|
|
51
|
+
function containsAscii(bytes, needle) {
|
|
52
|
+
const first = needle.charCodeAt(0);
|
|
53
|
+
const limit = bytes.length - needle.length;
|
|
54
|
+
for (let at = bytes.indexOf(first); at >= 0 && at <= limit; at = bytes.indexOf(first, at + 1)) {
|
|
55
|
+
let index = 1;
|
|
56
|
+
while (index < needle.length &&
|
|
57
|
+
bytes[at + index] === needle.charCodeAt(index))
|
|
58
|
+
index += 1;
|
|
59
|
+
if (index === needle.length)
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
const FEATURE_NOTES = {
|
|
65
|
+
docmdp: "a DocMDP certification that any change invalidates",
|
|
66
|
+
tagged: "a tagged structure that inserted content does not join",
|
|
67
|
+
pdfa: "a PDF/A claim that inserted standard fonts do not meet",
|
|
68
|
+
};
|
|
69
|
+
function firstChangeMessage(signatures, features) {
|
|
70
|
+
const notes = [
|
|
71
|
+
...(signatures > 0
|
|
72
|
+
? [
|
|
73
|
+
`${signatures} digital signature${signatures === 1 ? "" : "s"} that will not cover the edited revision`,
|
|
74
|
+
]
|
|
75
|
+
: []),
|
|
76
|
+
...features.map((feature) => FEATURE_NOTES[feature]),
|
|
77
|
+
];
|
|
78
|
+
return `The document carries ${notes.join("; ")}`;
|
|
79
|
+
}
|
|
80
|
+
/** Operations whose first created id a `"$<n>"` reference can name. */
|
|
81
|
+
const CREATING_OPERATIONS = new Set([
|
|
82
|
+
"insertTextBox",
|
|
83
|
+
"insertShape",
|
|
84
|
+
"insertImage",
|
|
85
|
+
"insertTable",
|
|
86
|
+
]);
|
|
87
|
+
function referenceOf(operation) {
|
|
88
|
+
const target = operation.target;
|
|
89
|
+
return typeof target === "string" ? parseReference(target) : undefined;
|
|
90
|
+
}
|
|
91
|
+
const handlers = {
|
|
92
|
+
insertTextBox: insertTextBox,
|
|
93
|
+
replaceText: replaceText,
|
|
94
|
+
setTextStyle: setTextStyle,
|
|
95
|
+
resizeElement: resizeElement,
|
|
96
|
+
moveElement: moveElement,
|
|
97
|
+
deleteElement: deleteElement,
|
|
98
|
+
insertPage: insertPage,
|
|
99
|
+
deletePage: deletePage,
|
|
100
|
+
movePage: movePage,
|
|
101
|
+
rotatePage: rotatePage,
|
|
102
|
+
insertShape: insertShape,
|
|
103
|
+
setShapeStyle: setShapeStyle,
|
|
104
|
+
insertImage: insertImage,
|
|
105
|
+
insertTable: insertTable,
|
|
106
|
+
setTableCell: setTableCell,
|
|
107
|
+
};
|
|
108
|
+
export class PdfEditDocument {
|
|
109
|
+
#pdfium;
|
|
110
|
+
#compact;
|
|
111
|
+
#original;
|
|
112
|
+
#fonts;
|
|
113
|
+
images = new ImageCache();
|
|
114
|
+
#limits;
|
|
115
|
+
#assets;
|
|
116
|
+
/** Signature fields in the original; an edit leaves them uncovering the new revision. */
|
|
117
|
+
#signatures;
|
|
118
|
+
/** What the first change will break or leave behind: certification, tagging, PDF/A. */
|
|
119
|
+
#features;
|
|
120
|
+
/** What the working copy was opened from: the original, or a checkpoint. */
|
|
121
|
+
#base;
|
|
122
|
+
#document;
|
|
123
|
+
#measurer;
|
|
124
|
+
#pages;
|
|
125
|
+
#batches = 0;
|
|
126
|
+
constructor(pdfium, original, fonts = new FontLibrary(async () => {
|
|
127
|
+
throw new Error("No font source is configured");
|
|
128
|
+
}), limits = defaultResourceLimits, assets = new AssetStore(), compact = compactPdf) {
|
|
129
|
+
this.#compact = compact;
|
|
130
|
+
this.#pdfium = pdfium;
|
|
131
|
+
this.#original = original;
|
|
132
|
+
this.#base = original;
|
|
133
|
+
this.#fonts = fonts;
|
|
134
|
+
this.#limits = limits;
|
|
135
|
+
this.#assets = assets;
|
|
136
|
+
this.#document = pdfium.openDocument(original);
|
|
137
|
+
this.#measurer = new TextMeasurer(pdfium, this.#document.handle);
|
|
138
|
+
this.#pages = this.#originalPages();
|
|
139
|
+
this.#signatures = pdfium.lib.FPDF_GetSignatureCount(this.#document.handle);
|
|
140
|
+
this.#features = detectFeatures(pdfium, this.#document.handle, original);
|
|
141
|
+
}
|
|
142
|
+
/** Features of the original a change affects, see `DocumentFeature`. */
|
|
143
|
+
get features() {
|
|
144
|
+
return this.#features;
|
|
145
|
+
}
|
|
146
|
+
/** Signature fields in the document. */
|
|
147
|
+
get signatureCount() {
|
|
148
|
+
return this.#signatures;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The families and texts a batch will draw, so the fonts can be fetched
|
|
152
|
+
* before the synchronous validation and application run.
|
|
153
|
+
*/
|
|
154
|
+
fontRequests(operations) {
|
|
155
|
+
return fontRequestsOf(operations, (id) => this.#locate(id)?.record.mark, (id) => this.getElement(id));
|
|
156
|
+
}
|
|
157
|
+
get pageCount() {
|
|
158
|
+
return this.#pages.length;
|
|
159
|
+
}
|
|
160
|
+
get pdfium() {
|
|
161
|
+
return this.#pdfium;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Checks a batch against the current document. Operations are checked one
|
|
165
|
+
* after another against the state before the batch, which is exact for
|
|
166
|
+
* everything but a page count another operation of the batch changes.
|
|
167
|
+
*/
|
|
168
|
+
validate(operations) {
|
|
169
|
+
const issues = [];
|
|
170
|
+
const context = this.#context(0, 0);
|
|
171
|
+
operations.forEach((operation, operationIndex) => {
|
|
172
|
+
const handler = handlers[operation.op];
|
|
173
|
+
if (!handler) {
|
|
174
|
+
issues.push({
|
|
175
|
+
operationIndex,
|
|
176
|
+
path: "/op",
|
|
177
|
+
code: "unknown-operation",
|
|
178
|
+
message: `Unknown pdf operation ${operation.op}`,
|
|
179
|
+
});
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const issue = issueCollector(operationIndex, issues);
|
|
183
|
+
const reference = referenceOf(operation);
|
|
184
|
+
if (reference !== undefined) {
|
|
185
|
+
// The element does not exist yet; the rest is checked when applying.
|
|
186
|
+
const creator = operations[reference];
|
|
187
|
+
if (reference >= operationIndex ||
|
|
188
|
+
!creator ||
|
|
189
|
+
!CREATING_OPERATIONS.has(creator.op))
|
|
190
|
+
issue("/target", "unknown-target", `"$${reference}" must name an earlier operation that creates an element`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
handler.validate(operation, context, issue);
|
|
194
|
+
});
|
|
195
|
+
return issues;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Applies a batch. Plain operation arrays, as the unit tests pass them, get
|
|
199
|
+
* the next batch number as their state id.
|
|
200
|
+
*/
|
|
201
|
+
apply(input) {
|
|
202
|
+
const batch = Array.isArray(input)
|
|
203
|
+
? { stateId: this.#batches + 1, operations: input }
|
|
204
|
+
: input;
|
|
205
|
+
const { stateId, operations } = batch;
|
|
206
|
+
const createdIds = [];
|
|
207
|
+
const createdByOperation = [];
|
|
208
|
+
const removedIds = [];
|
|
209
|
+
const changedPages = new Set();
|
|
210
|
+
const warnings = [];
|
|
211
|
+
// The first change of a signed, certified, tagged or PDF/A file is the
|
|
212
|
+
// point where those properties stop holding for what is shown; the
|
|
213
|
+
// warning names them once.
|
|
214
|
+
if (this.#batches === 0 && (this.#signatures > 0 || this.#features.length))
|
|
215
|
+
warnings.push({
|
|
216
|
+
code: "fidelity-degraded",
|
|
217
|
+
message: firstChangeMessage(this.#signatures, this.#features),
|
|
218
|
+
details: { signatures: this.#signatures, features: this.#features },
|
|
219
|
+
});
|
|
220
|
+
operations.forEach((raw, operationIndex) => {
|
|
221
|
+
const handler = handlers[raw.op];
|
|
222
|
+
if (!handler)
|
|
223
|
+
throw new ViewerError("internal", `Unknown pdf operation ${raw.op}`);
|
|
224
|
+
const context = this.#context(stateId, operationIndex);
|
|
225
|
+
const operation = this.#resolveReference(raw, operationIndex, createdByOperation, handler, context);
|
|
226
|
+
const result = handler.apply(operation, context);
|
|
227
|
+
createdIds.push(...result.createdIds);
|
|
228
|
+
createdByOperation[operationIndex] = [...result.createdIds];
|
|
229
|
+
removedIds.push(...(result.removedIds ?? []));
|
|
230
|
+
for (const pageIndex of result.changedPages)
|
|
231
|
+
changedPages.add(pageIndex);
|
|
232
|
+
warnings.push(...result.warnings);
|
|
233
|
+
});
|
|
234
|
+
this.#batches += 1;
|
|
235
|
+
return {
|
|
236
|
+
createdIds,
|
|
237
|
+
removedIds,
|
|
238
|
+
changedPages: [...changedPages].sort((a, b) => a - b),
|
|
239
|
+
pageCount: this.pageCount,
|
|
240
|
+
warnings,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Turns a `"$<n>"` target into the id operation `n` created, then runs the
|
|
245
|
+
* handler's own checks on the resolved operation: a reference that lands
|
|
246
|
+
* on an element the operation cannot act on fails the batch the same way
|
|
247
|
+
* validation would have.
|
|
248
|
+
*/
|
|
249
|
+
#resolveReference(operation, operationIndex, createdByOperation, handler, context) {
|
|
250
|
+
const reference = referenceOf(operation);
|
|
251
|
+
if (reference === undefined)
|
|
252
|
+
return operation;
|
|
253
|
+
const issues = [];
|
|
254
|
+
const issue = issueCollector(operationIndex, issues);
|
|
255
|
+
const id = createdByOperation[reference]?.[0];
|
|
256
|
+
if (id === undefined) {
|
|
257
|
+
issue("/target", "unknown-target", `Operation ${reference} created no element for "$${reference}"`);
|
|
258
|
+
throw invalidOperationError(issues);
|
|
259
|
+
}
|
|
260
|
+
const resolved = { ...operation, target: id };
|
|
261
|
+
handler.validate(resolved, context, issue);
|
|
262
|
+
if (issues.length > 0)
|
|
263
|
+
throw invalidOperationError(issues);
|
|
264
|
+
return resolved;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Bytes of the current state. The viewer reopens the incremental form,
|
|
268
|
+
* which is cheap to produce and read. A save defaults to a full rewrite —
|
|
269
|
+
* compacted, so deleted content is gone and the bytes do not depend on
|
|
270
|
+
* which pages were read — unless the file is signed, where the incremental
|
|
271
|
+
* form keeps the signed revision intact. Without changes either form is
|
|
272
|
+
* the bytes the document was opened from.
|
|
273
|
+
*/
|
|
274
|
+
materialize(purpose = "show", mode) {
|
|
275
|
+
return this.materializeDocument(purpose, mode).bytes;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The bytes of the current state. A full save goes through the compaction
|
|
279
|
+
* pass; when the pass cannot read PDFium's output, the uncompacted full
|
|
280
|
+
* save stands in — silently for the display copy, with a
|
|
281
|
+
* `privacy-not-guaranteed` warning for a save, since deleted content may
|
|
282
|
+
* then remain recoverable in the file (decided 2026-10-02). Signed files
|
|
283
|
+
* take the incremental form for both purposes so the signed revision
|
|
284
|
+
* stays intact in every base the session may restore from.
|
|
285
|
+
*/
|
|
286
|
+
materializeDocument(purpose = "show", mode) {
|
|
287
|
+
const chosen = purpose === "show"
|
|
288
|
+
? this.#signatures > 0
|
|
289
|
+
? "incremental"
|
|
290
|
+
: "full"
|
|
291
|
+
: (mode ?? (this.#signatures > 0 ? "incremental" : "full"));
|
|
292
|
+
if (this.#batches === 0 && this.#base === this.#original)
|
|
293
|
+
return { bytes: this.#original.slice(), warnings: [] };
|
|
294
|
+
if (chosen === "incremental")
|
|
295
|
+
return {
|
|
296
|
+
bytes: this.#batches === 0
|
|
297
|
+
? this.#base.slice()
|
|
298
|
+
: this.#document.save("incremental"),
|
|
299
|
+
warnings: [],
|
|
300
|
+
};
|
|
301
|
+
const full = this.#document.save("full");
|
|
302
|
+
try {
|
|
303
|
+
return { bytes: this.#compact(full), warnings: [] };
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
if (!(error instanceof PdfCompactionError))
|
|
307
|
+
throw error;
|
|
308
|
+
if (purpose === "show") {
|
|
309
|
+
console.warn(`web-doc: ${error.message}; the uncompacted full save is shown`);
|
|
310
|
+
return { bytes: full, warnings: [] };
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
bytes: full,
|
|
314
|
+
warnings: [
|
|
315
|
+
{
|
|
316
|
+
code: "privacy-not-guaranteed",
|
|
317
|
+
message: "The full save could not be compacted; deleted or replaced content may remain recoverable in the file",
|
|
318
|
+
details: { reason: "pdf-compaction", cause: error.message },
|
|
319
|
+
},
|
|
320
|
+
],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Rebuilds a state from its base (the original, or a checkpoint) and the
|
|
326
|
+
* batches after it. Plain arrays of batches, as the unit tests pass them,
|
|
327
|
+
* get state ids 1, 2, 3…
|
|
328
|
+
*/
|
|
329
|
+
restore(input) {
|
|
330
|
+
const target = Array.isArray(input)
|
|
331
|
+
? {
|
|
332
|
+
batches: input.map((operations, index) => ({ stateId: index + 1, operations })),
|
|
333
|
+
}
|
|
334
|
+
: input;
|
|
335
|
+
this.#fonts.release(this.#pdfium, this.#document.handle);
|
|
336
|
+
this.#document.close();
|
|
337
|
+
this.#base = target.base ?? this.#original;
|
|
338
|
+
this.#document = this.#pdfium.openDocument(this.#base);
|
|
339
|
+
this.#measurer = new TextMeasurer(this.#pdfium, this.#document.handle);
|
|
340
|
+
this.#pages = this.#originalPages();
|
|
341
|
+
this.#batches = 0;
|
|
342
|
+
for (const batch of target.batches)
|
|
343
|
+
this.apply(batch);
|
|
344
|
+
}
|
|
345
|
+
getElements(query) {
|
|
346
|
+
const pages = query.pageIndex === undefined
|
|
347
|
+
? this.#pages.map((_, index) => index)
|
|
348
|
+
: [query.pageIndex];
|
|
349
|
+
const result = [];
|
|
350
|
+
for (const pageIndex of pages) {
|
|
351
|
+
if (pageIndex < 0 || pageIndex >= this.#pages.length)
|
|
352
|
+
continue;
|
|
353
|
+
for (const element of this.#elementsOf(pageIndex)) {
|
|
354
|
+
if (query.kinds && !query.kinds.includes(element.kind))
|
|
355
|
+
continue;
|
|
356
|
+
if (query.intersects &&
|
|
357
|
+
!rectsIntersect(element.bounds, query.intersects))
|
|
358
|
+
continue;
|
|
359
|
+
result.push(element);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return result;
|
|
363
|
+
}
|
|
364
|
+
getElement(id) {
|
|
365
|
+
const pageIndex = this.#pageIndexOf(id);
|
|
366
|
+
if (pageIndex === undefined)
|
|
367
|
+
return undefined;
|
|
368
|
+
return this.#elementsOf(pageIndex).find((element) => element.id === id);
|
|
369
|
+
}
|
|
370
|
+
/** Elements under a point, top-most (drawn last) first. */
|
|
371
|
+
elementsAt(pageIndex, point) {
|
|
372
|
+
if (pageIndex < 0 || pageIndex >= this.#pages.length)
|
|
373
|
+
return [];
|
|
374
|
+
return this.#elementsOf(pageIndex)
|
|
375
|
+
.filter((element) => rectContains(element.bounds, point))
|
|
376
|
+
.reverse();
|
|
377
|
+
}
|
|
378
|
+
findText(query, options) {
|
|
379
|
+
if (!query)
|
|
380
|
+
return [];
|
|
381
|
+
const [first, last] = options.pageRange ?? [0, this.#pages.length - 1];
|
|
382
|
+
const limit = options.maxResults ?? Number.POSITIVE_INFINITY;
|
|
383
|
+
const targets = [];
|
|
384
|
+
for (let pageIndex = Math.max(0, first); pageIndex <= Math.min(last, this.#pages.length - 1) &&
|
|
385
|
+
targets.length < limit; pageIndex += 1)
|
|
386
|
+
this.#scanText(pageIndex, ({ textPage, geometry, byObject, offsets }) => {
|
|
387
|
+
for (const match of this.#matches(textPage, query, options)) {
|
|
388
|
+
if (targets.length >= limit)
|
|
389
|
+
break;
|
|
390
|
+
targets.push(this.#target(textPage, pageIndex, geometry, match, byObject, offsets));
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
return targets;
|
|
394
|
+
}
|
|
395
|
+
/** Lines, glyph boxes and styles of a text, text box or table element. */
|
|
396
|
+
textLayout(elementId) {
|
|
397
|
+
const pageIndex = this.#pageIndexOf(elementId);
|
|
398
|
+
if (pageIndex === undefined)
|
|
399
|
+
return undefined;
|
|
400
|
+
return this.#scanText(pageIndex, (scan) => {
|
|
401
|
+
const element = scan.elements.find((entry) => entry.id === elementId);
|
|
402
|
+
return element ? layoutOf(this.#pdfium, scan, element) : undefined;
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
/** The layouts of every text element on a page, with the page's displayed size. */
|
|
406
|
+
pageLayout(pageIndex) {
|
|
407
|
+
if (pageIndex < 0 || pageIndex >= this.#pages.length)
|
|
408
|
+
return undefined;
|
|
409
|
+
return this.#scanText(pageIndex, (scan) => {
|
|
410
|
+
const size = displayedSize(scan.geometry);
|
|
411
|
+
return {
|
|
412
|
+
pageIndex,
|
|
413
|
+
width: round(size.width),
|
|
414
|
+
height: round(size.height),
|
|
415
|
+
layouts: layoutsOf(this.#pdfium, scan),
|
|
416
|
+
};
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
/** The caret position nearest to a page-space point; none on a page without text. */
|
|
420
|
+
positionAt(pageIndex, point) {
|
|
421
|
+
if (pageIndex < 0 || pageIndex >= this.#pages.length)
|
|
422
|
+
return undefined;
|
|
423
|
+
return this.#scanText(pageIndex, (scan) => positionIn(this.#pdfium, scan, point));
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Renders a page with the listed elements inactive, as RGBA over white at
|
|
427
|
+
* `scale` device pixels per point. The objects are reactivated before the
|
|
428
|
+
* call returns, so nothing about the document changes.
|
|
429
|
+
*/
|
|
430
|
+
renderPageWithout(pageIndex, elementIds, scale) {
|
|
431
|
+
if (pageIndex < 0 || pageIndex >= this.#pages.length)
|
|
432
|
+
throw new ViewerError("invalid-operation", "No such page", {
|
|
433
|
+
details: { pageIndex },
|
|
434
|
+
});
|
|
435
|
+
if (!Number.isFinite(scale) || scale <= 0)
|
|
436
|
+
throw new ViewerError("invalid-operation", "The scale must be positive", {
|
|
437
|
+
details: { scale },
|
|
438
|
+
});
|
|
439
|
+
const { lib } = this.#pdfium;
|
|
440
|
+
return this.#withPage(pageIndex, (page, _textPage, geometry) => {
|
|
441
|
+
const size = displayedSize(geometry);
|
|
442
|
+
const width = Math.max(1, Math.ceil(size.width * scale));
|
|
443
|
+
const height = Math.max(1, Math.ceil(size.height * scale));
|
|
444
|
+
const pixels = width * height;
|
|
445
|
+
if (!Number.isSafeInteger(pixels) ||
|
|
446
|
+
pixels > this.#limits.maxDecodedPixels)
|
|
447
|
+
throw new ViewerError("resource-limit", "The rendered page exceeds maxDecodedPixels", { details: { actual: pixels, limit: this.#limits.maxDecodedPixels } });
|
|
448
|
+
const wanted = new Set(elementIds);
|
|
449
|
+
const records = this.#objectsOf(pageIndex, page);
|
|
450
|
+
const suppressed = [];
|
|
451
|
+
records.forEach((record, index) => {
|
|
452
|
+
if (!wanted.has(record.id))
|
|
453
|
+
return;
|
|
454
|
+
const object = lib.FPDFPage_GetObject(page, index);
|
|
455
|
+
const active = this.#pdfium.readNumbers(1, "i32", ([pointer]) => lib.FPDFPageObj_GetIsActive(object, pointer))?.[0] !== 0;
|
|
456
|
+
suppressed.push({ object, active });
|
|
457
|
+
lib.FPDFPageObj_SetIsActive(object, false);
|
|
458
|
+
});
|
|
459
|
+
try {
|
|
460
|
+
return {
|
|
461
|
+
pageIndex,
|
|
462
|
+
scale,
|
|
463
|
+
width,
|
|
464
|
+
height,
|
|
465
|
+
data: this.#render(page, width, height),
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
finally {
|
|
469
|
+
for (const { object, active } of suppressed)
|
|
470
|
+
lib.FPDFPageObj_SetIsActive(object, active);
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
/** RGBA pixels of a loaded page over white, through a BGRA bitmap in WASM memory. */
|
|
475
|
+
#render(page, width, height) {
|
|
476
|
+
const { lib } = this.#pdfium;
|
|
477
|
+
const stride = width * 4;
|
|
478
|
+
const buffer = this.#pdfium.malloc(stride * height);
|
|
479
|
+
try {
|
|
480
|
+
const bitmap = lib.FPDFBitmap_CreateEx(width, height, BITMAP_BGRA, buffer, stride);
|
|
481
|
+
if (!bitmap)
|
|
482
|
+
throw new ViewerError("render-failed", "PDFium could not create the bitmap", {
|
|
483
|
+
details: { width, height },
|
|
484
|
+
});
|
|
485
|
+
try {
|
|
486
|
+
lib.FPDFBitmap_FillRect(bitmap, 0, 0, width, height, 0xffffffff);
|
|
487
|
+
lib.FPDF_RenderPageBitmap(bitmap, page, 0, 0, width, height, 0, RENDER_ANNOTATIONS);
|
|
488
|
+
}
|
|
489
|
+
finally {
|
|
490
|
+
lib.FPDFBitmap_Destroy(bitmap);
|
|
491
|
+
}
|
|
492
|
+
const bgra = this.#pdfium.readBytes(buffer, stride * height);
|
|
493
|
+
const rgba = new Uint8Array(bgra.byteLength);
|
|
494
|
+
for (let offset = 0; offset < bgra.byteLength; offset += 4) {
|
|
495
|
+
rgba[offset] = bgra[offset + 2];
|
|
496
|
+
rgba[offset + 1] = bgra[offset + 1];
|
|
497
|
+
rgba[offset + 2] = bgra[offset];
|
|
498
|
+
rgba[offset + 3] = bgra[offset + 3];
|
|
499
|
+
}
|
|
500
|
+
return rgba.buffer;
|
|
501
|
+
}
|
|
502
|
+
finally {
|
|
503
|
+
this.#pdfium.free(buffer);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/** The rectangles a range covers, one per line fragment, in reading order. */
|
|
507
|
+
rangeRects(range) {
|
|
508
|
+
const pageIndex = this.#pageIndexOf(range.start.elementId);
|
|
509
|
+
if (pageIndex === undefined ||
|
|
510
|
+
pageIndex !== this.#pageIndexOf(range.end.elementId))
|
|
511
|
+
return [];
|
|
512
|
+
return this.#scanText(pageIndex, (scan) => rectsOf(this.#pdfium, scan, range));
|
|
513
|
+
}
|
|
514
|
+
dispose() {
|
|
515
|
+
this.#fonts.release(this.#pdfium, this.#document.handle);
|
|
516
|
+
this.#document.close();
|
|
517
|
+
}
|
|
518
|
+
#context(stateId, operationIndex) {
|
|
519
|
+
let created = 0;
|
|
520
|
+
return {
|
|
521
|
+
pdfium: this.#pdfium,
|
|
522
|
+
document: this.#document.handle,
|
|
523
|
+
measurer: this.#measurer,
|
|
524
|
+
fonts: this.#fonts,
|
|
525
|
+
images: this.images,
|
|
526
|
+
limits: this.#limits,
|
|
527
|
+
assets: this.#assets,
|
|
528
|
+
pageCount: this.pageCount,
|
|
529
|
+
geometry: (pageIndex) => {
|
|
530
|
+
const record = this.#pages[pageIndex];
|
|
531
|
+
if (!record)
|
|
532
|
+
throw new ViewerError("internal", `No page ${pageIndex}`);
|
|
533
|
+
return (record.geometry ??
|
|
534
|
+
this.#withPage(pageIndex, (page) => this.#geometryOf(pageIndex, page)));
|
|
535
|
+
},
|
|
536
|
+
// Ids name the state the batch leads to, the operation and the item,
|
|
537
|
+
// so replaying the history reproduces them and an undone id is never
|
|
538
|
+
// handed out again.
|
|
539
|
+
newId: (pageIndex, suffix = "") => `${this.#pages[pageIndex].key}:n${stateId}.${operationIndex}.${created++}${suffix}`,
|
|
540
|
+
withPage: (pageIndex, use) => this.#writePage(pageIndex, use),
|
|
541
|
+
appendObjects: (pageIndex, records) => {
|
|
542
|
+
const page = this.#pages[pageIndex];
|
|
543
|
+
page.objects = [...(page.objects ?? []), ...records];
|
|
544
|
+
delete page.elements;
|
|
545
|
+
},
|
|
546
|
+
locate: (id) => this.#locate(id),
|
|
547
|
+
element: (id) => this.getElement(id),
|
|
548
|
+
pageElementIds: (pageIndex) => this.#elementsOf(pageIndex).map((element) => element.id),
|
|
549
|
+
pageSize: (pageIndex) => displayedSize(this.#withPage(pageIndex, (page) => this.#geometryOf(pageIndex, page))),
|
|
550
|
+
insertPageRecord: (index) => {
|
|
551
|
+
const key = `q${stateId}.${operationIndex}`;
|
|
552
|
+
this.#pages.splice(index, 0, { key });
|
|
553
|
+
this.#forgetElements();
|
|
554
|
+
return key;
|
|
555
|
+
},
|
|
556
|
+
removePageRecord: (index) => {
|
|
557
|
+
this.#pages.splice(index, 1);
|
|
558
|
+
this.#forgetElements();
|
|
559
|
+
},
|
|
560
|
+
movePageRecord: (from, to) => {
|
|
561
|
+
const [record] = this.#pages.splice(from, 1);
|
|
562
|
+
this.#pages.splice(to, 0, record);
|
|
563
|
+
this.#forgetElements();
|
|
564
|
+
},
|
|
565
|
+
invalidatePage: (index) => {
|
|
566
|
+
const page = this.#pages[index];
|
|
567
|
+
if (!page)
|
|
568
|
+
return;
|
|
569
|
+
delete page.geometry;
|
|
570
|
+
delete page.elements;
|
|
571
|
+
},
|
|
572
|
+
spliceObjects: (pageIndex, start, count, records) => {
|
|
573
|
+
const page = this.#pages[pageIndex];
|
|
574
|
+
const objects = [...(page.objects ?? [])];
|
|
575
|
+
objects.splice(start, count, ...records);
|
|
576
|
+
page.objects = objects;
|
|
577
|
+
delete page.elements;
|
|
578
|
+
},
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
/** Cached elements carry page indexes, which a structure change makes stale. */
|
|
582
|
+
#forgetElements() {
|
|
583
|
+
for (const page of this.#pages)
|
|
584
|
+
delete page.elements;
|
|
585
|
+
}
|
|
586
|
+
#locate(id) {
|
|
587
|
+
const pageIndex = this.#pageIndexOf(id);
|
|
588
|
+
if (pageIndex === undefined)
|
|
589
|
+
return undefined;
|
|
590
|
+
const records = this.#withPage(pageIndex, (page) => this.#objectsOf(pageIndex, page));
|
|
591
|
+
const indexes = [];
|
|
592
|
+
records.forEach((record, index) => {
|
|
593
|
+
if (record.id === id)
|
|
594
|
+
indexes.push(index);
|
|
595
|
+
});
|
|
596
|
+
const record = records[indexes[0] ?? -1];
|
|
597
|
+
return record ? { pageIndex, indexes, record } : undefined;
|
|
598
|
+
}
|
|
599
|
+
/** Loads a page, lets `use` change it, regenerates its content stream. */
|
|
600
|
+
#writePage(pageIndex, use) {
|
|
601
|
+
const { lib } = this.#pdfium;
|
|
602
|
+
const page = lib.FPDF_LoadPage(this.#document.handle, pageIndex);
|
|
603
|
+
if (!page)
|
|
604
|
+
throw new ViewerError("render-failed", "PDFium could not load the page", {
|
|
605
|
+
details: { pageIndex },
|
|
606
|
+
});
|
|
607
|
+
try {
|
|
608
|
+
// Objects are assigned before the change so appended ones follow them.
|
|
609
|
+
this.#objectsOf(pageIndex, page);
|
|
610
|
+
const result = use(page);
|
|
611
|
+
if (!lib.FPDFPage_GenerateContent(page))
|
|
612
|
+
throw new ViewerError("edit-failed", "PDFium could not rewrite the page", {
|
|
613
|
+
details: { stage: "apply", pageIndex },
|
|
614
|
+
});
|
|
615
|
+
delete this.#pages[pageIndex].elements;
|
|
616
|
+
return result;
|
|
617
|
+
}
|
|
618
|
+
finally {
|
|
619
|
+
lib.FPDF_ClosePage(page);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
#originalPages() {
|
|
623
|
+
const count = this.#pdfium.lib.FPDF_GetPageCount(this.#document.handle);
|
|
624
|
+
return Array.from({ length: count }, (_, index) => ({ key: `p${index}` }));
|
|
625
|
+
}
|
|
626
|
+
#pageIndexOf(id) {
|
|
627
|
+
const key = id.split(":")[0];
|
|
628
|
+
const index = this.#pages.findIndex((page) => page.key === key);
|
|
629
|
+
return index < 0 ? undefined : index;
|
|
630
|
+
}
|
|
631
|
+
#elementsOf(pageIndex) {
|
|
632
|
+
const record = this.#pages[pageIndex];
|
|
633
|
+
if (record.elements)
|
|
634
|
+
return record.elements;
|
|
635
|
+
return this.#withPage(pageIndex, (page, textPage, geometry) => {
|
|
636
|
+
const { elements } = scanPage(this.#pdfium, page, textPage, pageIndex, geometry, this.#objectsOf(pageIndex, page));
|
|
637
|
+
record.elements = Object.freeze(elements);
|
|
638
|
+
return record.elements;
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
/** Object records of a page, assigned on first sight; `page` must be loaded. */
|
|
642
|
+
#objectsOf(pageIndex, page) {
|
|
643
|
+
const record = this.#pages[pageIndex];
|
|
644
|
+
if (record.objects)
|
|
645
|
+
return record.objects;
|
|
646
|
+
const { lib } = this.#pdfium;
|
|
647
|
+
const count = lib.FPDFPage_CountObjects(page);
|
|
648
|
+
const objects = [];
|
|
649
|
+
for (let index = 0; index < count; index += 1) {
|
|
650
|
+
const object = lib.FPDFPage_GetObject(page, index);
|
|
651
|
+
const mark = readMark(this.#pdfium, object);
|
|
652
|
+
objects.push({
|
|
653
|
+
id: mark ? mark.id : `${record.key}:o${index}`,
|
|
654
|
+
type: lib.FPDFPageObj_GetType(object),
|
|
655
|
+
...(mark ? { mark: this.#ownMark(mark, record.key) } : {}),
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
// A table's inputs sit on its first object; members without that head
|
|
659
|
+
// are plain objects again. So is a group whose objects no longer match
|
|
660
|
+
// the inputs its mark stores.
|
|
661
|
+
const heads = new Map();
|
|
662
|
+
const members = new Map();
|
|
663
|
+
objects.forEach((object, index) => {
|
|
664
|
+
if (!object.mark)
|
|
665
|
+
return;
|
|
666
|
+
if (!heads.has(object.id))
|
|
667
|
+
heads.set(object.id, object.mark.kind === "table"
|
|
668
|
+
? tableSpecOf(object.mark) && object.mark
|
|
669
|
+
: object.mark);
|
|
670
|
+
members.set(object.id, [...(members.get(object.id) ?? []), index]);
|
|
671
|
+
});
|
|
672
|
+
const stale = new Set();
|
|
673
|
+
if (members.size > 0) {
|
|
674
|
+
const geometry = this.#geometryOf(pageIndex, page);
|
|
675
|
+
const textPage = lib.FPDFText_LoadPage(page);
|
|
676
|
+
try {
|
|
677
|
+
for (const [id, indexes] of members) {
|
|
678
|
+
const head = heads.get(id);
|
|
679
|
+
if (!head ||
|
|
680
|
+
!markIsFresh(this.#pdfium, page, textPage, geometry, head, indexes))
|
|
681
|
+
stale.add(id);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
finally {
|
|
685
|
+
lib.FPDFText_ClosePage(textPage);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
record.objects = objects.map((object, index) => object.mark && stale.has(object.id)
|
|
689
|
+
? { id: `${record.key}:o${index}`, type: object.type }
|
|
690
|
+
: object);
|
|
691
|
+
return record.objects;
|
|
692
|
+
}
|
|
693
|
+
/** Marks from other sessions keep their id only if it cannot collide with ours. */
|
|
694
|
+
#ownMark(mark, pageKey) {
|
|
695
|
+
return mark.id.startsWith(`${pageKey}:`)
|
|
696
|
+
? mark
|
|
697
|
+
: { ...mark, id: `${pageKey}:${mark.id}` };
|
|
698
|
+
}
|
|
699
|
+
#geometryOf(pageIndex, page) {
|
|
700
|
+
const record = this.#pages[pageIndex];
|
|
701
|
+
if (record.geometry)
|
|
702
|
+
return record.geometry;
|
|
703
|
+
const { lib } = this.#pdfium;
|
|
704
|
+
const box = this.#pdfium.readNumbers(4, "float", ([pointer]) => lib.FPDF_GetPageBoundingBox(page, pointer));
|
|
705
|
+
const [left, top, right, bottom] = box ?? [
|
|
706
|
+
0,
|
|
707
|
+
lib.FPDF_GetPageHeightF(page),
|
|
708
|
+
lib.FPDF_GetPageWidthF(page),
|
|
709
|
+
0,
|
|
710
|
+
];
|
|
711
|
+
record.geometry = {
|
|
712
|
+
box: { left: left, bottom: bottom, right: right, top: top },
|
|
713
|
+
rotation: lib.FPDFPage_GetRotation(page),
|
|
714
|
+
};
|
|
715
|
+
return record.geometry;
|
|
716
|
+
}
|
|
717
|
+
/** Loads a page with its text page and the character-to-element mapping. */
|
|
718
|
+
#scanText(pageIndex, use) {
|
|
719
|
+
return this.#withPage(pageIndex, (page, textPage, geometry) => {
|
|
720
|
+
const records = this.#objectsOf(pageIndex, page);
|
|
721
|
+
const { byObject, elements } = scanPage(this.#pdfium, page, textPage, pageIndex, geometry, records);
|
|
722
|
+
const offsets = this.#charOffsets(page, textPage, byObject, new Map(elements.map((element) => [element.id, element.text ?? ""])));
|
|
723
|
+
return use({ page, textPage, geometry, byObject, elements, offsets });
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
#withPage(pageIndex, use) {
|
|
727
|
+
const { lib } = this.#pdfium;
|
|
728
|
+
const page = lib.FPDF_LoadPage(this.#document.handle, pageIndex);
|
|
729
|
+
if (!page)
|
|
730
|
+
throw new ViewerError("render-failed", "PDFium could not load the page", {
|
|
731
|
+
details: { pageIndex },
|
|
732
|
+
});
|
|
733
|
+
try {
|
|
734
|
+
const textPage = lib.FPDFText_LoadPage(page);
|
|
735
|
+
try {
|
|
736
|
+
return use(page, textPage, this.#geometryOf(pageIndex, page));
|
|
737
|
+
}
|
|
738
|
+
finally {
|
|
739
|
+
lib.FPDFText_ClosePage(textPage);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
finally {
|
|
743
|
+
lib.FPDF_ClosePage(page);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
*#matches(textPage, query, options) {
|
|
747
|
+
const { lib } = this.#pdfium;
|
|
748
|
+
const count = lib.FPDFText_CountChars(textPage);
|
|
749
|
+
if (count === 0)
|
|
750
|
+
return;
|
|
751
|
+
const bytes = (count + 1) * 2;
|
|
752
|
+
const buffer = this.#pdfium.malloc(bytes);
|
|
753
|
+
let text;
|
|
754
|
+
try {
|
|
755
|
+
lib.FPDFText_GetText(textPage, 0, count, buffer);
|
|
756
|
+
text = this.#pdfium.readWideStringAt(buffer, bytes);
|
|
757
|
+
}
|
|
758
|
+
finally {
|
|
759
|
+
this.#pdfium.free(buffer);
|
|
760
|
+
}
|
|
761
|
+
const haystack = options.caseSensitive ? text : text.toLowerCase();
|
|
762
|
+
const needle = options.caseSensitive ? query : query.toLowerCase();
|
|
763
|
+
let from = 0;
|
|
764
|
+
while (from <= haystack.length - needle.length) {
|
|
765
|
+
const at = haystack.indexOf(needle, from);
|
|
766
|
+
if (at < 0)
|
|
767
|
+
return;
|
|
768
|
+
// Text indexes count UTF-16 units; PDFium's char indexes do not.
|
|
769
|
+
const start = lib.FPDFText_GetCharIndexFromTextIndex(textPage, at);
|
|
770
|
+
const end = lib.FPDFText_GetCharIndexFromTextIndex(textPage, at + needle.length - 1);
|
|
771
|
+
if (start >= 0 && end >= start)
|
|
772
|
+
yield { start, end: end + 1 };
|
|
773
|
+
from = at + Math.max(1, needle.length);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* For every character of the text page: the element it belongs to and its
|
|
778
|
+
* offset in that element's text, so matches can be reported as ranges.
|
|
779
|
+
* Lines of a text box or cells of a table are separate objects; their
|
|
780
|
+
* offsets are found by locating each object's text inside the element's.
|
|
781
|
+
*/
|
|
782
|
+
#charOffsets(page, textPage, byObject, elementTexts) {
|
|
783
|
+
const { lib } = this.#pdfium;
|
|
784
|
+
const count = lib.FPDFText_CountChars(textPage);
|
|
785
|
+
const positions = [];
|
|
786
|
+
const seen = new Map();
|
|
787
|
+
const cursors = new Map();
|
|
788
|
+
const bases = new Map();
|
|
789
|
+
for (let index = 0; index < count; index += 1) {
|
|
790
|
+
const object = lib.FPDFText_GetTextObject(textPage, index);
|
|
791
|
+
const elementId = byObject.get(object);
|
|
792
|
+
if (!object || elementId === undefined) {
|
|
793
|
+
positions.push(undefined);
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
if (!bases.has(object)) {
|
|
797
|
+
const own = this.#pdfium.readWideString((buffer, bytes) => lib.FPDFTextObj_GetText(object, textPage, buffer, bytes));
|
|
798
|
+
const whole = elementTexts.get(elementId) ?? "";
|
|
799
|
+
const from = cursors.get(elementId) ?? 0;
|
|
800
|
+
const at = whole.indexOf(own, from);
|
|
801
|
+
const base = at >= 0 ? at : whole.indexOf(own.trim(), from);
|
|
802
|
+
bases.set(object, base >= 0 ? base : from);
|
|
803
|
+
cursors.set(elementId, (base >= 0 ? base : from) + own.length);
|
|
804
|
+
}
|
|
805
|
+
const within = seen.get(object) ?? 0;
|
|
806
|
+
seen.set(object, within + 1);
|
|
807
|
+
positions.push({ elementId, offset: bases.get(object) + within });
|
|
808
|
+
}
|
|
809
|
+
void page;
|
|
810
|
+
return positions;
|
|
811
|
+
}
|
|
812
|
+
#target(textPage, pageIndex, geometry, match, byObject, offsets) {
|
|
813
|
+
const { lib } = this.#pdfium;
|
|
814
|
+
const ranges = [];
|
|
815
|
+
const rects = [];
|
|
816
|
+
const ids = [];
|
|
817
|
+
let text = "";
|
|
818
|
+
for (let index = match.start; index < match.end; index += 1) {
|
|
819
|
+
const box = this.#pdfium.readNumbers(4, "double", ([l, r, b, t]) => lib.FPDFText_GetCharBox(textPage, index, l, r, b, t));
|
|
820
|
+
const unit = lib.FPDFText_GetUnicode(textPage, index);
|
|
821
|
+
text += String.fromCodePoint(unit);
|
|
822
|
+
const id = byObject.get(lib.FPDFText_GetTextObject(textPage, index));
|
|
823
|
+
if (id && !ids.includes(id))
|
|
824
|
+
ids.push(id);
|
|
825
|
+
const position = offsets[index];
|
|
826
|
+
if (position) {
|
|
827
|
+
const last = ranges.at(-1);
|
|
828
|
+
if (last &&
|
|
829
|
+
last.end.elementId === position.elementId &&
|
|
830
|
+
last.end.offset === position.offset)
|
|
831
|
+
ranges[ranges.length - 1] = {
|
|
832
|
+
start: last.start,
|
|
833
|
+
end: { elementId: position.elementId, offset: position.offset + 1 },
|
|
834
|
+
};
|
|
835
|
+
else
|
|
836
|
+
ranges.push({
|
|
837
|
+
start: position,
|
|
838
|
+
end: { elementId: position.elementId, offset: position.offset + 1 },
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
if (!box)
|
|
842
|
+
continue;
|
|
843
|
+
const [left, right, bottom, top] = box;
|
|
844
|
+
if (left === right || bottom === top)
|
|
845
|
+
continue;
|
|
846
|
+
const current = rects.at(-1);
|
|
847
|
+
// Chars on one line share their vertical extent; a new line starts a new box.
|
|
848
|
+
if (current && bottom < current.top && top > current.bottom) {
|
|
849
|
+
current.left = Math.min(current.left, left);
|
|
850
|
+
current.right = Math.max(current.right, right);
|
|
851
|
+
current.bottom = Math.min(current.bottom, bottom);
|
|
852
|
+
current.top = Math.max(current.top, top);
|
|
853
|
+
}
|
|
854
|
+
else
|
|
855
|
+
rects.push({ left, bottom, right, top });
|
|
856
|
+
}
|
|
857
|
+
const pageRects = rects.map((rect) => roundRect(userRectToPage(geometry, rect.left, rect.bottom, rect.right, rect.top)));
|
|
858
|
+
return {
|
|
859
|
+
pageIndex,
|
|
860
|
+
text,
|
|
861
|
+
rects: pageRects.length > 0
|
|
862
|
+
? pageRects
|
|
863
|
+
: [roundRect(unionRects([{ x: 0, y: 0, width: 0, height: 0 }]))],
|
|
864
|
+
elementIds: ids,
|
|
865
|
+
ranges,
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
}
|