web-doc 0.10.0 → 0.12.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/dist/adapters/office.js +4 -2
- package/dist/adapters/pdf.js +19 -8
- package/dist/edit/docx/engine.d.ts +9 -1
- package/dist/edit/docx/engine.js +22 -0
- package/dist/edit/docx/provider.d.ts +3 -1
- package/dist/edit/docx/provider.js +3 -0
- package/dist/edit/docx/session.d.ts +6 -2
- package/dist/edit/docx/session.js +7 -1
- package/dist/edit/docx/style.d.ts +1 -1
- package/dist/edit/docx/style.js +3 -2
- package/dist/edit/docx/types.d.ts +11 -1
- package/dist/edit/engine.d.ts +2 -1
- package/dist/edit/pdf/engine/document.d.ts +4 -2
- package/dist/edit/pdf/engine/document.js +115 -12
- package/dist/edit/pdf/engine/elements.d.ts +5 -1
- package/dist/edit/pdf/engine/elements.js +88 -6
- package/dist/edit/pdf/engine/existing-text.js +16 -61
- package/dist/edit/pdf/engine/fonts.d.ts +14 -0
- package/dist/edit/pdf/engine/fonts.js +114 -0
- package/dist/edit/pdf/engine/handler.js +2 -0
- package/dist/edit/pdf/engine/layout.js +1 -1
- package/dist/edit/pdf/engine/operations.d.ts +7 -1
- package/dist/edit/pdf/engine/paragraph-edit.d.ts +4 -0
- package/dist/edit/pdf/engine/paragraph-edit.js +331 -0
- package/dist/edit/pdf/engine/paragraph.d.ts +31 -0
- package/dist/edit/pdf/engine/paragraph.js +445 -0
- package/dist/edit/pdf/engine/pdfium.d.ts +3 -0
- package/dist/edit/pdf/engine/text-box.js +16 -3
- package/dist/edit/pdf/engine/text-font.js +2 -19
- package/dist/edit/pdf/engine/transform.js +14 -3
- package/dist/edit/pdf/provider.d.ts +3 -1
- package/dist/edit/pdf/provider.js +3 -0
- package/dist/edit/pdf/range-map.d.ts +2 -1
- package/dist/edit/pdf/range-map.js +38 -8
- package/dist/edit/pdf/schemas.d.ts +2 -0
- package/dist/edit/pdf/schemas.js +37 -0
- package/dist/edit/pdf/session.d.ts +3 -1
- package/dist/edit/pdf/session.js +35 -6
- package/dist/edit/pdf/types.d.ts +38 -8
- package/dist/edit/pptx/elements.d.ts +4 -1
- package/dist/edit/pptx/elements.js +35 -0
- package/dist/edit/pptx/engine.d.ts +5 -1
- package/dist/edit/pptx/engine.js +11 -0
- package/dist/edit/pptx/handler.js +8 -0
- package/dist/edit/pptx/provider.d.ts +3 -1
- package/dist/edit/pptx/provider.js +3 -0
- package/dist/edit/pptx/session.d.ts +6 -2
- package/dist/edit/pptx/session.js +7 -1
- package/dist/edit/pptx/types.d.ts +11 -1
- package/dist/edit/range-style.d.ts +24 -0
- package/dist/edit/range-style.js +46 -0
- package/dist/edit/session.js +5 -0
- package/dist/edit/types.d.ts +13 -0
- package/dist/viewport.js +11 -0
- package/dist/worker-protocol.d.ts +1 -1
- package/dist/workers/ooxml-edit-worker.js +90 -2
- package/dist/workers/pdf-edit-worker.js +2019 -1003
- package/package.json +1 -1
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { OBJECT_TEXT, paragraphSpecOf, } from "./elements.js";
|
|
2
|
+
import { roundRect, round, unionRects, userToPage, } from "./geometry.js";
|
|
3
|
+
import { fontBytes, fontCanDraw } from "./fonts.js";
|
|
4
|
+
export function paragraphElement(paragraph) {
|
|
5
|
+
return {
|
|
6
|
+
id: paragraph.id,
|
|
7
|
+
kind: "paragraph",
|
|
8
|
+
pageIndex: paragraph.pageIndex,
|
|
9
|
+
text: paragraph.text,
|
|
10
|
+
bounds: paragraph.bounds,
|
|
11
|
+
textStyle: paragraph.textStyle,
|
|
12
|
+
operations: [
|
|
13
|
+
"replaceParagraphText",
|
|
14
|
+
"replaceText",
|
|
15
|
+
"setTextStyle",
|
|
16
|
+
"deleteElement",
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const MIN_CONTINUATION_FILL = 0.9;
|
|
21
|
+
const MAX_CONNECTED_EDGE_GAP = 0.65;
|
|
22
|
+
const MAX_LEFT_ALIGNMENT_DELTA = 1;
|
|
23
|
+
const ADJACENT_BASELINE_EM = 0.25;
|
|
24
|
+
const ADJACENT_TEXT_GAP_EM = 3;
|
|
25
|
+
const LIST_ITEM_START = /^(?:[•◦▪‣·*–—-]|\(?\d{1,3}[.)]|\(?[a-z][.)])\s/iu;
|
|
26
|
+
const NUMERIC_VALUE = /^[\d\s.,:/%()+−–—-]+$/u;
|
|
27
|
+
// Public PDF geometry is canonicalized to thousandths of a point. This only
|
|
28
|
+
// absorbs native floating-point rounding, not visible tracking or kerning.
|
|
29
|
+
const GLYPH_POSITION_PRECISION = 0.001;
|
|
30
|
+
const MAX_PAIR_ADJUSTMENT_EM = 0.1;
|
|
31
|
+
const MATRIX_RELATIVE_PRECISION = 0.000001;
|
|
32
|
+
/**
|
|
33
|
+
* Conservative import recognition. Only whole, equally styled native lines
|
|
34
|
+
* with uniform upright matrices and regular left-aligned leading participate.
|
|
35
|
+
* Content-stream order is deliberately irrelevant to reading order.
|
|
36
|
+
*/
|
|
37
|
+
export function discoverParagraphs(pdfium, page, pageIndex, geometry, records, elements, textPage, measurer) {
|
|
38
|
+
const result = new Map();
|
|
39
|
+
if (geometry.rotation !== 0)
|
|
40
|
+
return result;
|
|
41
|
+
const indexesById = new Map();
|
|
42
|
+
records.forEach((record, index) => {
|
|
43
|
+
const indexes = indexesById.get(record.id) ?? [];
|
|
44
|
+
indexes.push(index);
|
|
45
|
+
indexesById.set(record.id, indexes);
|
|
46
|
+
});
|
|
47
|
+
const elementsById = new Map();
|
|
48
|
+
for (const element of elements)
|
|
49
|
+
if (!elementsById.has(element.id))
|
|
50
|
+
elementsById.set(element.id, element);
|
|
51
|
+
for (const element of elements) {
|
|
52
|
+
const indexes = indexesById.get(element.id) ?? [];
|
|
53
|
+
const spec = paragraphSpecOf(records[indexes[0] ?? -1]?.mark);
|
|
54
|
+
if (!spec)
|
|
55
|
+
continue;
|
|
56
|
+
const paragraph = {
|
|
57
|
+
id: element.id,
|
|
58
|
+
pageIndex,
|
|
59
|
+
text: spec.text,
|
|
60
|
+
bounds: spec.rect,
|
|
61
|
+
textStyle: spec.style,
|
|
62
|
+
memberIds: [element.id],
|
|
63
|
+
members: [{ elementId: element.id, start: 0, end: spec.text.length }],
|
|
64
|
+
};
|
|
65
|
+
result.set(element.id, { paragraph, spec, indexes });
|
|
66
|
+
}
|
|
67
|
+
// Arbitrarily rotated pages/objects require transformed layout and are left
|
|
68
|
+
// independently editable until that native layout contract exists.
|
|
69
|
+
const rows = [];
|
|
70
|
+
const uprightTexts = new Map();
|
|
71
|
+
const fontKeys = new Map();
|
|
72
|
+
const programs = [];
|
|
73
|
+
const fontKey = (font) => {
|
|
74
|
+
const cached = fontKeys.get(font);
|
|
75
|
+
if (cached)
|
|
76
|
+
return cached;
|
|
77
|
+
let key;
|
|
78
|
+
if (!pdfium.lib.FPDFFont_GetIsEmbedded(font))
|
|
79
|
+
key = `standard:${pdfium.readUtf8String((buffer, bytes) => pdfium.lib.FPDFFont_GetBaseFontName(font, buffer, bytes))}`;
|
|
80
|
+
else {
|
|
81
|
+
const bytes = fontBytes(pdfium, font);
|
|
82
|
+
let at = bytes
|
|
83
|
+
? programs.findIndex((program) => program.length === bytes.length &&
|
|
84
|
+
program.every((byte, index) => byte === bytes[index]))
|
|
85
|
+
: -1;
|
|
86
|
+
if (at < 0 && bytes) {
|
|
87
|
+
at = programs.length;
|
|
88
|
+
programs.push(bytes);
|
|
89
|
+
}
|
|
90
|
+
key = bytes ? `embedded:${at}` : `unreadable:${font}`;
|
|
91
|
+
}
|
|
92
|
+
fontKeys.set(font, key);
|
|
93
|
+
return key;
|
|
94
|
+
};
|
|
95
|
+
const { lib } = pdfium;
|
|
96
|
+
const rightEdges = new Map();
|
|
97
|
+
const glyphsByObject = new Map();
|
|
98
|
+
for (let at = 0; at < lib.FPDFText_CountChars(textPage); at += 1) {
|
|
99
|
+
const unicode = lib.FPDFText_GetUnicode(textPage, at);
|
|
100
|
+
if (!Number.isInteger(unicode) ||
|
|
101
|
+
unicode < 32 ||
|
|
102
|
+
unicode > 0x10ffff ||
|
|
103
|
+
(unicode >= 0xd800 && unicode <= 0xdfff))
|
|
104
|
+
continue;
|
|
105
|
+
const object = lib.FPDFText_GetTextObject(textPage, at);
|
|
106
|
+
if (!object)
|
|
107
|
+
continue;
|
|
108
|
+
const text = String.fromCodePoint(unicode);
|
|
109
|
+
const origin = pdfium.readNumbers(2, "double", ([x, y]) => lib.FPDFText_GetCharOrigin(textPage, at, x, y));
|
|
110
|
+
if (origin) {
|
|
111
|
+
const point = userToPage(geometry, origin[0], origin[1]);
|
|
112
|
+
const glyphs = glyphsByObject.get(object) ?? [];
|
|
113
|
+
glyphs.push({ text, ...point });
|
|
114
|
+
glyphsByObject.set(object, glyphs);
|
|
115
|
+
}
|
|
116
|
+
if (/\s/u.test(text))
|
|
117
|
+
continue;
|
|
118
|
+
const box = pdfium.readNumbers(4, "float", ([out]) => lib.FPDFText_GetLooseCharBox(textPage, at, out));
|
|
119
|
+
if (box)
|
|
120
|
+
rightEdges.set(object, Math.max(rightEdges.get(object) ?? -Infinity, userToPage(geometry, box[2], box[1]).x));
|
|
121
|
+
}
|
|
122
|
+
records.forEach((record, index) => {
|
|
123
|
+
if (record.mark ||
|
|
124
|
+
record.staleMarkId ||
|
|
125
|
+
record.type !== OBJECT_TEXT ||
|
|
126
|
+
record.id.includes("/"))
|
|
127
|
+
return;
|
|
128
|
+
const element = elementsById.get(record.id);
|
|
129
|
+
if (!element?.text?.trim())
|
|
130
|
+
return;
|
|
131
|
+
const object = lib.FPDFPage_GetObject(page, index);
|
|
132
|
+
const matrix = pdfium.readNumbers(6, "float", ([out]) => lib.FPDFPageObj_GetMatrix(object, out));
|
|
133
|
+
if (!matrix ||
|
|
134
|
+
matrix[0] <= 0 ||
|
|
135
|
+
matrix[3] <= 0 ||
|
|
136
|
+
Math.abs(matrix[1]) > 0.001 ||
|
|
137
|
+
Math.abs(matrix[2]) > 0.001)
|
|
138
|
+
return;
|
|
139
|
+
const origin = userToPage(geometry, matrix[4], matrix[5]);
|
|
140
|
+
uprightTexts.set(element.id, { element, baseline: origin.y });
|
|
141
|
+
if (!element.textStyle ||
|
|
142
|
+
element.text !== element.text.trimStart() ||
|
|
143
|
+
element.rotation ||
|
|
144
|
+
element.text.includes("\u0002") ||
|
|
145
|
+
/[\r\n\t]/u.test(element.text) ||
|
|
146
|
+
LIST_ITEM_START.test(element.text) ||
|
|
147
|
+
NUMERIC_VALUE.test(element.text) ||
|
|
148
|
+
Math.abs(matrix[0] - matrix[3]) >
|
|
149
|
+
Math.max(matrix[0], matrix[3]) * MATRIX_RELATIVE_PRECISION)
|
|
150
|
+
return;
|
|
151
|
+
const rgba = pdfium.readNumbers(4, "i32", ([r, g, b, a]) => lib.FPDFPageObj_GetFillColor(object, r, g, b, a));
|
|
152
|
+
if (rgba?.[3] !== 255 || lib.FPDFTextObj_GetTextRenderMode(object) !== 0)
|
|
153
|
+
return;
|
|
154
|
+
const font = lib.FPDFTextObj_GetFont(object);
|
|
155
|
+
const rawSize = pdfium.readNumbers(1, "float", ([out]) => lib.FPDFTextObj_GetFontSize(object, out))?.[0];
|
|
156
|
+
if (!rawSize)
|
|
157
|
+
return;
|
|
158
|
+
const effective = {
|
|
159
|
+
...element,
|
|
160
|
+
textStyle: {
|
|
161
|
+
...element.textStyle,
|
|
162
|
+
fontSize: round(rawSize * matrix[0]),
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
if (effective.textStyle.fontSize < 1 || effective.textStyle.fontSize > 500)
|
|
166
|
+
return;
|
|
167
|
+
// Text/size reflow recomputes default native advances; bounded source pair
|
|
168
|
+
// adjustments are not retained. Color-only changes retain source positions.
|
|
169
|
+
// Non-writable fonts require the separate explicit substitution warning.
|
|
170
|
+
if (fontCanDraw(pdfium, font, element.text) &&
|
|
171
|
+
!hasSupportedAdvances(glyphsByObject.get(object) ?? [], element.text, font, rawSize * matrix[0], measurer))
|
|
172
|
+
return;
|
|
173
|
+
const width = (rightEdges.get(object) ?? element.bounds.x + element.bounds.width) -
|
|
174
|
+
origin.x;
|
|
175
|
+
if (!(width > 0) || element.bounds.width > width + 2)
|
|
176
|
+
return;
|
|
177
|
+
rows.push({
|
|
178
|
+
element: effective,
|
|
179
|
+
index,
|
|
180
|
+
font: fontKey(font),
|
|
181
|
+
fontHandle: font,
|
|
182
|
+
x: origin.x,
|
|
183
|
+
baseline: origin.y,
|
|
184
|
+
width,
|
|
185
|
+
firstWordAdvance: observedFirstWordAdvance(glyphsByObject.get(object) ?? [], element.text),
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
rows.sort((a, b) => a.baseline - b.baseline || a.x - b.x);
|
|
189
|
+
// Neighboring cells/list labels share a baseline and a nearby horizontal
|
|
190
|
+
// gap. Distant columns and rotated sidebars do not invalidate body text.
|
|
191
|
+
// Keep blocked rows in `aligned` so leading inference is unchanged.
|
|
192
|
+
const blockedRows = rowsWithAdjacentText(rows, [...uprightTexts.values()]);
|
|
193
|
+
const used = new Set();
|
|
194
|
+
for (const first of rows) {
|
|
195
|
+
if (used.has(first.element.id) || blockedRows.has(first.element.id))
|
|
196
|
+
continue;
|
|
197
|
+
const aligned = rows.filter((row) => !used.has(row.element.id) &&
|
|
198
|
+
row.baseline >= first.baseline &&
|
|
199
|
+
compatible(first, row));
|
|
200
|
+
const gaps = aligned
|
|
201
|
+
.slice(1)
|
|
202
|
+
.map((row, index) => row.baseline - aligned[index].baseline)
|
|
203
|
+
.filter((gap) => gap >= first.element.textStyle.fontSize * 0.8 &&
|
|
204
|
+
gap <= first.element.textStyle.fontSize * 2.1);
|
|
205
|
+
if (gaps.length === 0)
|
|
206
|
+
continue;
|
|
207
|
+
const leading = Math.min(...gaps);
|
|
208
|
+
const group = [];
|
|
209
|
+
for (const row of aligned) {
|
|
210
|
+
const previous = group.at(-1);
|
|
211
|
+
if (previous &&
|
|
212
|
+
(Math.abs(row.baseline - previous.baseline - leading) >
|
|
213
|
+
Math.max(1, leading * 0.15) ||
|
|
214
|
+
/[-\u00ad\u0002]$/u.test(previous.element.text.trim())))
|
|
215
|
+
break;
|
|
216
|
+
group.push(row);
|
|
217
|
+
}
|
|
218
|
+
if (group.length < 2 ||
|
|
219
|
+
group.some((row) => blockedRows.has(row.element.id)))
|
|
220
|
+
continue;
|
|
221
|
+
const bounds = unionRects(group.map((row) => row.element.bounds));
|
|
222
|
+
const rect = roundRect({
|
|
223
|
+
x: first.x,
|
|
224
|
+
y: bounds.y,
|
|
225
|
+
width: Math.max(...group.map((row) => row.width)),
|
|
226
|
+
height: bounds.height,
|
|
227
|
+
});
|
|
228
|
+
const ids = new Set(group.map((row) => row.element.id));
|
|
229
|
+
const firstIndex = Math.min(...group.map((row) => row.index));
|
|
230
|
+
// A short non-final line needs native evidence that the next word could
|
|
231
|
+
// not fit; otherwise it may end an independent paragraph.
|
|
232
|
+
if (group
|
|
233
|
+
.slice(0, -1)
|
|
234
|
+
.some((row, index) => !continuesAtWrap(row, group[index + 1], rect.width, pdfium, measurer)) ||
|
|
235
|
+
elements.some((element) => {
|
|
236
|
+
if (ids.has(element.id) || !element.text?.trim())
|
|
237
|
+
return false;
|
|
238
|
+
const b = element.bounds;
|
|
239
|
+
const upright = uprightTexts.get(element.id);
|
|
240
|
+
const sharesRow = upright && group.some((row) => isAdjacentText(row, upright));
|
|
241
|
+
if (sharesRow)
|
|
242
|
+
return true;
|
|
243
|
+
// Do not expose the matching tail/head of an unsupported connected
|
|
244
|
+
// section (indentation, mixed style, excluded source characters).
|
|
245
|
+
const sharesColumn = b.x < rect.x + rect.width && b.x + b.width > rect.x;
|
|
246
|
+
const edgeGap = Math.max(b.y - (rect.y + rect.height), rect.y - (b.y + b.height));
|
|
247
|
+
return sharesColumn && edgeGap <= leading * MAX_CONNECTED_EDGE_GAP;
|
|
248
|
+
}))
|
|
249
|
+
continue;
|
|
250
|
+
// Ambiguous interleaving/overlap may be another run, heading or figure.
|
|
251
|
+
// A background completely behind the group is safe to keep untouched.
|
|
252
|
+
if (elements.some((element) => !ids.has(element.id) &&
|
|
253
|
+
intersects(rect, element.bounds) &&
|
|
254
|
+
!backgroundBehind(element, rect, records, firstIndex)))
|
|
255
|
+
continue;
|
|
256
|
+
const id = `${first.element.id}:paragraph`;
|
|
257
|
+
if (elementsById.has(id))
|
|
258
|
+
continue;
|
|
259
|
+
let text = "";
|
|
260
|
+
const members = group.map((row) => {
|
|
261
|
+
if (text)
|
|
262
|
+
text += " ";
|
|
263
|
+
const start = text.length;
|
|
264
|
+
text += row.element.text.trim();
|
|
265
|
+
return { elementId: row.element.id, start, end: text.length };
|
|
266
|
+
});
|
|
267
|
+
if (text.length > 20000)
|
|
268
|
+
continue;
|
|
269
|
+
const style = {
|
|
270
|
+
...first.element.textStyle,
|
|
271
|
+
lineHeight: leading / first.element.textStyle.fontSize,
|
|
272
|
+
};
|
|
273
|
+
const paragraph = {
|
|
274
|
+
id,
|
|
275
|
+
pageIndex,
|
|
276
|
+
text,
|
|
277
|
+
bounds: rect,
|
|
278
|
+
textStyle: style,
|
|
279
|
+
memberIds: [...ids],
|
|
280
|
+
members,
|
|
281
|
+
};
|
|
282
|
+
const target = {
|
|
283
|
+
paragraph,
|
|
284
|
+
spec: {
|
|
285
|
+
kind: "paragraph",
|
|
286
|
+
id,
|
|
287
|
+
text,
|
|
288
|
+
lines: group.map((row) => row.element.text.trim()),
|
|
289
|
+
rect,
|
|
290
|
+
style,
|
|
291
|
+
baselineOffset: (first.baseline - rect.y) / style.fontSize,
|
|
292
|
+
},
|
|
293
|
+
indexes: group.map((row) => row.index).sort((a, b) => a - b),
|
|
294
|
+
};
|
|
295
|
+
result.set(id, target);
|
|
296
|
+
for (const member of members) {
|
|
297
|
+
result.set(member.elementId, target);
|
|
298
|
+
used.add(member.elementId);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
function compatible(a, b) {
|
|
304
|
+
return (Math.abs(a.x - b.x) <= MAX_LEFT_ALIGNMENT_DELTA && sameTypography(a, b));
|
|
305
|
+
}
|
|
306
|
+
function sameTypography(a, b) {
|
|
307
|
+
const x = a.element.textStyle;
|
|
308
|
+
const y = b.element.textStyle;
|
|
309
|
+
return (a.font === b.font &&
|
|
310
|
+
x.fontSize === y.fontSize &&
|
|
311
|
+
x.color === y.color &&
|
|
312
|
+
x.bold === y.bold &&
|
|
313
|
+
x.italic === y.italic);
|
|
314
|
+
}
|
|
315
|
+
function rowsWithAdjacentText(rows, texts) {
|
|
316
|
+
texts.sort((a, b) => a.baseline - b.baseline);
|
|
317
|
+
const blocked = new Set();
|
|
318
|
+
for (const row of rows) {
|
|
319
|
+
const tolerance = row.element.textStyle.fontSize * ADJACENT_BASELINE_EM;
|
|
320
|
+
let low = 0;
|
|
321
|
+
let high = texts.length;
|
|
322
|
+
while (low < high) {
|
|
323
|
+
const mid = (low + high) >>> 1;
|
|
324
|
+
if (texts[mid].baseline < row.baseline - tolerance)
|
|
325
|
+
low = mid + 1;
|
|
326
|
+
else
|
|
327
|
+
high = mid;
|
|
328
|
+
}
|
|
329
|
+
for (let at = low; at < texts.length; at += 1) {
|
|
330
|
+
const other = texts[at];
|
|
331
|
+
if (other.baseline > row.baseline + tolerance)
|
|
332
|
+
break;
|
|
333
|
+
if (isAdjacentText(row, other)) {
|
|
334
|
+
blocked.add(row.element.id);
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return blocked;
|
|
340
|
+
}
|
|
341
|
+
function isAdjacentText(row, other) {
|
|
342
|
+
if (row.element.id === other.element.id)
|
|
343
|
+
return false;
|
|
344
|
+
const size = row.element.textStyle.fontSize;
|
|
345
|
+
if (Math.abs(row.baseline - other.baseline) > size * ADJACENT_BASELINE_EM)
|
|
346
|
+
return false;
|
|
347
|
+
const a = row.element.bounds;
|
|
348
|
+
const b = other.element.bounds;
|
|
349
|
+
const gap = Math.max(b.x - (a.x + a.width), a.x - (b.x + b.width));
|
|
350
|
+
return gap <= size * ADJACENT_TEXT_GAP_EM;
|
|
351
|
+
}
|
|
352
|
+
function continuesAtWrap(row, next, width, pdfium, measurer) {
|
|
353
|
+
if (row.width >= width * MIN_CONTINUATION_FILL)
|
|
354
|
+
return true;
|
|
355
|
+
// Source glyph origins also work for fonts whose encoding cannot be reused
|
|
356
|
+
// (for example a subset missing U+0020). Do not measure its notdef space.
|
|
357
|
+
if (next.firstWordAdvance !== undefined)
|
|
358
|
+
return row.width + next.firstWordAdvance > width + GLYPH_POSITION_PRECISION;
|
|
359
|
+
const nextWord = next.element.text.trim().split(/\s/u)[0];
|
|
360
|
+
if (!nextWord || !fontCanDraw(pdfium, row.fontHandle, ` ${nextWord}`))
|
|
361
|
+
return false;
|
|
362
|
+
const needed = measurer.advance(row.fontHandle, row.element.textStyle.fontSize, ` ${nextWord}`);
|
|
363
|
+
return (Number.isFinite(needed) &&
|
|
364
|
+
row.width + needed > width + GLYPH_POSITION_PRECISION);
|
|
365
|
+
}
|
|
366
|
+
function observedFirstWordAdvance(glyphs, text) {
|
|
367
|
+
const prefix = /^\S+\s+/u.exec(text)?.[0];
|
|
368
|
+
if (!prefix)
|
|
369
|
+
return undefined;
|
|
370
|
+
const count = [...prefix].length;
|
|
371
|
+
const first = glyphs[0];
|
|
372
|
+
const nextWord = glyphs[count];
|
|
373
|
+
if (!first ||
|
|
374
|
+
!nextWord ||
|
|
375
|
+
glyphs
|
|
376
|
+
.slice(0, count)
|
|
377
|
+
.map((glyph) => glyph.text)
|
|
378
|
+
.join("") !== prefix)
|
|
379
|
+
return undefined;
|
|
380
|
+
const advance = nextWord.x - first.x;
|
|
381
|
+
return Number.isFinite(advance) && advance > 0 ? advance : undefined;
|
|
382
|
+
}
|
|
383
|
+
/** A bounded eligibility rule, not a claim that source TJ positioning survives reflow. */
|
|
384
|
+
function hasSupportedAdvances(glyphs, text, font, fontSize, measurer) {
|
|
385
|
+
const characters = [...text.trimEnd()];
|
|
386
|
+
const first = glyphs[0];
|
|
387
|
+
if (!first || glyphs.length < characters.length)
|
|
388
|
+
return false;
|
|
389
|
+
const pairLimit = fontSize * MAX_PAIR_ADJUSTMENT_EM;
|
|
390
|
+
let previousAdvance = 0;
|
|
391
|
+
let cumulativeAdjustment = 0;
|
|
392
|
+
let adjustedPairs = 0;
|
|
393
|
+
let defaultPairs = 0;
|
|
394
|
+
for (const [index, character] of characters.entries()) {
|
|
395
|
+
const glyph = glyphs[index];
|
|
396
|
+
if (!glyph ||
|
|
397
|
+
glyph.text !== character ||
|
|
398
|
+
!Number.isFinite(glyph.x) ||
|
|
399
|
+
!Number.isFinite(glyph.y) ||
|
|
400
|
+
Math.abs(glyph.y - first.y) > GLYPH_POSITION_PRECISION)
|
|
401
|
+
return false;
|
|
402
|
+
const previous = glyphs[index - 1];
|
|
403
|
+
if (previous) {
|
|
404
|
+
const adjustment = glyph.x - previous.x - previousAdvance;
|
|
405
|
+
const changed = Math.abs(adjustment) > GLYPH_POSITION_PRECISION;
|
|
406
|
+
// Real and inferred word spaces cannot be treated as pair adjustments.
|
|
407
|
+
if (/\s/u.test(previous.text + character)) {
|
|
408
|
+
if (changed)
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
else if (changed) {
|
|
412
|
+
if (Math.abs(adjustment) > pairLimit)
|
|
413
|
+
return false;
|
|
414
|
+
adjustedPairs += 1;
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
defaultPairs += 1;
|
|
418
|
+
}
|
|
419
|
+
cumulativeAdjustment += adjustment;
|
|
420
|
+
if (Math.abs(cumulativeAdjustment) > pairLimit)
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
previousAdvance = measurer.advance(font, fontSize, character);
|
|
424
|
+
if (!Number.isFinite(previousAdvance) || previousAdvance <= 0)
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
// Uniform tracking cannot pass merely because its per-glyph change is tiny.
|
|
428
|
+
return adjustedPairs === 0 || defaultPairs > adjustedPairs;
|
|
429
|
+
}
|
|
430
|
+
export function intersects(a, b) {
|
|
431
|
+
return (a.x < b.x + b.width &&
|
|
432
|
+
a.x + a.width > b.x &&
|
|
433
|
+
a.y < b.y + b.height &&
|
|
434
|
+
a.y + a.height > b.y);
|
|
435
|
+
}
|
|
436
|
+
function backgroundBehind(element, rect, records, first) {
|
|
437
|
+
const b = element.bounds;
|
|
438
|
+
return (element.kind !== "text" &&
|
|
439
|
+
element.text === undefined &&
|
|
440
|
+
records.findIndex((record) => record.id === element.id) < first &&
|
|
441
|
+
b.x <= rect.x &&
|
|
442
|
+
b.y <= rect.y &&
|
|
443
|
+
b.x + b.width >= rect.x + rect.width &&
|
|
444
|
+
b.y + b.height >= rect.y + rect.height);
|
|
445
|
+
}
|
|
@@ -59,6 +59,7 @@ export interface PdfiumFunctions {
|
|
|
59
59
|
FPDFPath_SetDrawMode(path: number, fillMode: number, stroke: boolean): boolean;
|
|
60
60
|
FPDFTextObj_GetText(textObject: number, textPage: number, buffer: number, bytes: number): number;
|
|
61
61
|
FPDFTextObj_GetFontSize(textObject: number, size: number): boolean;
|
|
62
|
+
FPDFTextObj_GetTextRenderMode(textObject: number): number;
|
|
62
63
|
FPDFTextObj_GetFont(textObject: number): number;
|
|
63
64
|
FPDFFont_GetFamilyName(font: number, buffer: number, bytes: number): number;
|
|
64
65
|
FPDFFont_GetBaseFontName(font: number, buffer: number, bytes: number): number;
|
|
@@ -69,6 +70,8 @@ export interface PdfiumFunctions {
|
|
|
69
70
|
FPDFPageObj_Destroy(object: number): void;
|
|
70
71
|
FPDFPage_RemoveObject(page: number, object: number): boolean;
|
|
71
72
|
FPDFFont_GetWeight(font: number): number;
|
|
73
|
+
/** The advance of the character the font encodes `glyph`, a UTF-16 code unit, as: the PDF's own width for it. */
|
|
74
|
+
FPDFFont_GetGlyphWidth(font: number, glyph: number, fontSize: number, width: number): boolean;
|
|
72
75
|
FPDFText_GetCharBox(textPage: number, index: number, left: number, right: number, bottom: number, top: number): boolean;
|
|
73
76
|
FPDFText_GetTextObject(textPage: number, index: number): number;
|
|
74
77
|
FPDFText_GetUnicode(textPage: number, index: number): number;
|
|
@@ -124,9 +124,9 @@ export function removeObjects(context, location) {
|
|
|
124
124
|
const object = lib.FPDFPage_GetObject(page, index);
|
|
125
125
|
lib.FPDFPage_RemoveObject(page, object);
|
|
126
126
|
lib.FPDFPageObj_Destroy(object);
|
|
127
|
+
context.spliceObjects(location.pageIndex, index, 1, []);
|
|
127
128
|
}
|
|
128
129
|
});
|
|
129
|
-
context.spliceObjects(location.pageIndex, first, location.indexes.length, []);
|
|
130
130
|
return first;
|
|
131
131
|
}
|
|
132
132
|
/** The location and stored inputs of a text box, or the issue that stops the edit. */
|
|
@@ -194,6 +194,7 @@ export function fontRequestsOf(operations, markOf, elementOf) {
|
|
|
194
194
|
text: operation.text,
|
|
195
195
|
});
|
|
196
196
|
break;
|
|
197
|
+
case "replaceParagraphText":
|
|
197
198
|
case "replaceText": {
|
|
198
199
|
const box = spec(operation.target);
|
|
199
200
|
if (box) {
|
|
@@ -201,14 +202,26 @@ export function fontRequestsOf(operations, markOf, elementOf) {
|
|
|
201
202
|
break;
|
|
202
203
|
}
|
|
203
204
|
const element = elementOf(operation.target);
|
|
204
|
-
if (element?.kind === "text")
|
|
205
|
+
if (element?.kind === "text" || element?.kind === "paragraph")
|
|
205
206
|
requests.push({
|
|
206
207
|
family: element.textStyle?.fontFamily ?? "Helvetica",
|
|
207
|
-
text: operation.
|
|
208
|
+
text: element.kind === "paragraph" && operation.range
|
|
209
|
+
? (element.text ?? "").slice(0, operation.range.start.offset) +
|
|
210
|
+
operation.text +
|
|
211
|
+
(element.text ?? "").slice(operation.range.end.offset)
|
|
212
|
+
: operation.text,
|
|
208
213
|
});
|
|
209
214
|
break;
|
|
210
215
|
}
|
|
211
216
|
case "setTextStyle": {
|
|
217
|
+
const paragraph = elementOf(operation.target);
|
|
218
|
+
if (paragraph?.kind === "paragraph" &&
|
|
219
|
+
operation.style.fontSize !== undefined &&
|
|
220
|
+
operation.style.fontSize !== paragraph.textStyle?.fontSize)
|
|
221
|
+
requests.push({
|
|
222
|
+
family: paragraph.textStyle?.fontFamily ?? "Helvetica",
|
|
223
|
+
text: paragraph.text ?? "",
|
|
224
|
+
});
|
|
212
225
|
const box = spec(operation.target);
|
|
213
226
|
if (box)
|
|
214
227
|
requests.push({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { glyphUnicode, readCff } from "./cff.js";
|
|
2
2
|
import { textStyle } from "./elements.js";
|
|
3
|
+
import { fontBytes } from "./fonts.js";
|
|
3
4
|
import { cffOpenType, completeSfnt, hasUnicodeCmap } from "./opentype.js";
|
|
4
5
|
export function textFaceOf(pdfium, object, cache) {
|
|
5
6
|
const { lib } = pdfium;
|
|
@@ -9,7 +10,7 @@ export function textFaceOf(pdfium, object, cache) {
|
|
|
9
10
|
if (!lib.FPDFFont_GetIsEmbedded(font))
|
|
10
11
|
return { key: `name:${baseName}`, family, missing: "not-embedded" };
|
|
11
12
|
// A Type 3 font is embedded as drawings, with no program to read.
|
|
12
|
-
const program =
|
|
13
|
+
const program = fontBytes(pdfium, font);
|
|
13
14
|
if (!program)
|
|
14
15
|
return { key: `embedded:${baseName}`, family, missing: "unreadable" };
|
|
15
16
|
const key = `${baseName}#${fnv1a(program).toString(16)}`;
|
|
@@ -54,24 +55,6 @@ function faceOf(program, baseName) {
|
|
|
54
55
|
? { face: { data, format: "opentype" } }
|
|
55
56
|
: { missing: "no-unicode" };
|
|
56
57
|
}
|
|
57
|
-
/** The font program a PDF font object embeds, or `undefined` for a font it does not embed. */
|
|
58
|
-
function fontProgram(pdfium, font) {
|
|
59
|
-
const { lib } = pdfium;
|
|
60
|
-
const size = pdfium.readNumbers(1, "i32", ([out]) => lib.FPDFFont_GetFontData(font, 0, 0, out))?.[0];
|
|
61
|
-
if (!size)
|
|
62
|
-
return undefined;
|
|
63
|
-
const buffer = pdfium.malloc(size);
|
|
64
|
-
const out = pdfium.malloc(4);
|
|
65
|
-
try {
|
|
66
|
-
return lib.FPDFFont_GetFontData(font, buffer, size, out)
|
|
67
|
-
? pdfium.readBytes(buffer, size)
|
|
68
|
-
: undefined;
|
|
69
|
-
}
|
|
70
|
-
finally {
|
|
71
|
-
pdfium.free(out);
|
|
72
|
-
pdfium.free(buffer);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
58
|
/** FNV-1a over the program: a cheap, stable key for one font. */
|
|
76
59
|
function fnv1a(bytes) {
|
|
77
60
|
let hash = 0x811c9dc5;
|
|
@@ -11,6 +11,10 @@ export const moveElement = {
|
|
|
11
11
|
const target = anyTarget(operation.target, context, issue);
|
|
12
12
|
if (!target)
|
|
13
13
|
return;
|
|
14
|
+
if (target.element.kind === "paragraph") {
|
|
15
|
+
issue("/target", "unsupported-target", "Imported paragraphs cannot be moved");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
14
18
|
if ((operation.to === undefined) === (operation.by === undefined)) {
|
|
15
19
|
issue("", "one-of", "Give exactly one of `to` and `by`");
|
|
16
20
|
return;
|
|
@@ -52,8 +56,9 @@ export const resizeElement = {
|
|
|
52
56
|
const target = anyTarget(operation.target, context, issue);
|
|
53
57
|
if (!target)
|
|
54
58
|
return;
|
|
55
|
-
if (target.element.kind === "table"
|
|
56
|
-
|
|
59
|
+
if (target.element.kind === "table" ||
|
|
60
|
+
target.element.kind === "paragraph") {
|
|
61
|
+
issue("/target", "unsupported-target", "Tables and imported paragraphs cannot be resized");
|
|
57
62
|
return;
|
|
58
63
|
}
|
|
59
64
|
validateRect(operation.rect, context.geometry(target.location.pageIndex), issue);
|
|
@@ -97,10 +102,16 @@ export const deleteElement = {
|
|
|
97
102
|
},
|
|
98
103
|
apply(operation, context) {
|
|
99
104
|
const { location } = anyTarget(operation.target, context);
|
|
105
|
+
const paragraph = context.paragraph(operation.target)?.paragraph;
|
|
100
106
|
removeObjects(context, location);
|
|
101
107
|
return {
|
|
102
108
|
...changed(location, { overflow: false }),
|
|
103
|
-
removedIds: [
|
|
109
|
+
removedIds: [
|
|
110
|
+
...new Set([
|
|
111
|
+
operation.target,
|
|
112
|
+
...(paragraph?.id === operation.target ? paragraph.memberIds : []),
|
|
113
|
+
]),
|
|
114
|
+
],
|
|
104
115
|
};
|
|
105
116
|
},
|
|
106
117
|
};
|
|
@@ -2,9 +2,10 @@ import { type WorkerLike } from "../../worker-client.js";
|
|
|
2
2
|
import type { EditEngineContext } from "../engine.js";
|
|
3
3
|
import type { PagePoint, PageRect, TextPosition, TextRange } from "../types.js";
|
|
4
4
|
import { WorkerEngineClient } from "../worker-engine.js";
|
|
5
|
-
import type { PageBitmap, PageLayout, TextFont, TextLayout } from "./types.js";
|
|
5
|
+
import type { PageBitmap, PageLayout, PdfTextParagraph, TextFont, TextLayout } from "./types.js";
|
|
6
6
|
/** The reads behind the overlay primitives, beyond the core engine interface. */
|
|
7
7
|
export interface PdfEngineReads {
|
|
8
|
+
textParagraph?(id: string, signal: AbortSignal): Promise<PdfTextParagraph | undefined>;
|
|
8
9
|
textLayout(id: string, signal: AbortSignal): Promise<TextLayout | undefined>;
|
|
9
10
|
textFont(id: string, signal: AbortSignal): Promise<TextFont | undefined>;
|
|
10
11
|
positionAt(pageIndex: number, point: PagePoint, signal: AbortSignal): Promise<TextPosition | undefined>;
|
|
@@ -28,6 +29,7 @@ export declare function loadPdfEditEngine(original: Uint8Array, context: EditEng
|
|
|
28
29
|
export declare class PdfEditEngineClient extends WorkerEngineClient implements PdfEngineReads {
|
|
29
30
|
readonly schemas: import("../types.js").OperationSchemaSet;
|
|
30
31
|
start(original: Uint8Array, options: PdfEditProviderOptions): Promise<void>;
|
|
32
|
+
textParagraph(id: string, signal: AbortSignal): Promise<PdfTextParagraph | undefined>;
|
|
31
33
|
textLayout(id: string, signal: AbortSignal): Promise<TextLayout | undefined>;
|
|
32
34
|
textFont(id: string, signal: AbortSignal): Promise<TextFont | undefined>;
|
|
33
35
|
positionAt(pageIndex: number, point: PagePoint, signal: AbortSignal): Promise<TextPosition | undefined>;
|
|
@@ -50,6 +50,9 @@ export class PdfEditEngineClient extends WorkerEngineClient {
|
|
|
50
50
|
.filter((source) => source instanceof ArrayBuffer),
|
|
51
51
|
]);
|
|
52
52
|
}
|
|
53
|
+
textParagraph(id, signal) {
|
|
54
|
+
return this.request("edit-text-paragraph", { id }, signal);
|
|
55
|
+
}
|
|
53
56
|
textLayout(id, signal) {
|
|
54
57
|
return this.request("edit-text-layout", { id }, signal);
|
|
55
58
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EditOperation, EditReceipt, TextRange } from "../types.js";
|
|
1
|
+
import type { EditOperation, EditReceipt, TextRange, TextAnchorMigration } from "../types.js";
|
|
2
2
|
export interface MutationRecord {
|
|
3
3
|
/** The revision the call produced. */
|
|
4
4
|
readonly revision: number;
|
|
@@ -10,6 +10,7 @@ export interface MutationRecord {
|
|
|
10
10
|
* offsets inside surviving elements are left as they were.
|
|
11
11
|
*/
|
|
12
12
|
readonly operations: readonly EditOperation[];
|
|
13
|
+
readonly textAnchorMigrations?: readonly TextAnchorMigration[];
|
|
13
14
|
readonly receipt: EditReceipt;
|
|
14
15
|
}
|
|
15
16
|
/** The range after the records, or `undefined` when its element is gone. */
|