web-doc 0.12.0 → 0.13.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.
@@ -36,9 +36,9 @@ export declare class PdfEditDocument {
36
36
  get pageCount(): number;
37
37
  get pdfium(): Pdfium;
38
38
  /**
39
- * Checks a batch against the current document. Operations are checked one
40
- * after another against the state before the batch, which is exact for
41
- * everything but a page count another operation of the batch changes.
39
+ * Preflights a batch against its starting document. Applying revalidates
40
+ * each operation against earlier changes in the batch; created-element
41
+ * references are checked once their targets exist.
42
42
  */
43
43
  validate(operations: readonly PdfOrUnknownOperation[]): OperationIssue[];
44
44
  /**
@@ -167,9 +167,9 @@ export class PdfEditDocument {
167
167
  return this.#pdfium;
168
168
  }
169
169
  /**
170
- * Checks a batch against the current document. Operations are checked one
171
- * after another against the state before the batch, which is exact for
172
- * everything but a page count another operation of the batch changes.
170
+ * Preflights a batch against its starting document. Applying revalidates
171
+ * each operation against earlier changes in the batch; created-element
172
+ * references are checked once their targets exist.
173
173
  */
174
174
  validate(operations) {
175
175
  const issues = [];
@@ -229,7 +229,7 @@ export class PdfEditDocument {
229
229
  if (!handler)
230
230
  throw new ViewerError("internal", `Unknown pdf operation ${raw.op}`);
231
231
  const context = this.#context(stateId, operationIndex);
232
- const operation = this.#resolveReference(raw, operationIndex, createdByOperation, handler, context);
232
+ const operation = this.#resolveAndValidate(raw, operationIndex, createdByOperation, handler, context);
233
233
  const result = handler.apply(operation, context);
234
234
  createdIds.push(...result.createdIds);
235
235
  createdByOperation[operationIndex] = [...result.createdIds];
@@ -251,23 +251,23 @@ export class PdfEditDocument {
251
251
  };
252
252
  }
253
253
  /**
254
- * Turns a `"$<n>"` target into the id operation `n` created, then runs the
255
- * handler's own checks on the resolved operation: a reference that lands
256
- * on an element the operation cannot act on fails the batch the same way
257
- * validation would have.
254
+ * Resolves a `"$<n>"` target, then checks every operation against the
255
+ * current document. Earlier operations may have moved, resized or removed
256
+ * an ordinary target since the batch's initial preflight.
258
257
  */
259
- #resolveReference(operation, operationIndex, createdByOperation, handler, context) {
258
+ #resolveAndValidate(operation, operationIndex, createdByOperation, handler, context) {
260
259
  const reference = referenceOf(operation);
261
- if (reference === undefined)
262
- return operation;
263
260
  const issues = [];
264
261
  const issue = issueCollector(operationIndex, issues);
265
- const id = createdByOperation[reference]?.[0];
266
- if (id === undefined) {
267
- issue("/target", "unknown-target", `Operation ${reference} created no element for "$${reference}"`);
268
- throw invalidOperationError(issues);
262
+ let resolved = operation;
263
+ if (reference !== undefined && "target" in operation) {
264
+ const id = createdByOperation[reference]?.[0];
265
+ if (id === undefined) {
266
+ issue("/target", "unknown-target", `Operation ${reference} created no element for "$${reference}"`);
267
+ throw invalidOperationError(issues);
268
+ }
269
+ resolved = { ...operation, target: id };
269
270
  }
270
- const resolved = { ...operation, target: id };
271
271
  handler.validate(resolved, context, issue);
272
272
  if (issues.length > 0)
273
273
  throw invalidOperationError(issues);
@@ -888,7 +888,21 @@ export class PdfEditDocument {
888
888
  const records = this.#objectsOf(pageIndex, page);
889
889
  const { byObject, elements } = scanPage(this.#pdfium, page, textPage, pageIndex, geometry, records);
890
890
  const offsets = this.#charOffsets(page, textPage, byObject, new Map(elements.map((element) => [element.id, element.text ?? ""])));
891
- return use({ page, textPage, geometry, byObject, elements, offsets });
891
+ // A text box lays its lines out in the rect its mark keeps; only
892
+ // fresh marks are still on the records.
893
+ const frames = new Map();
894
+ for (const record of records)
895
+ if (record.mark?.kind === "textBox" && !frames.has(record.id))
896
+ frames.set(record.id, roundRect(record.mark.rect));
897
+ return use({
898
+ page,
899
+ textPage,
900
+ geometry,
901
+ byObject,
902
+ elements,
903
+ offsets,
904
+ frames,
905
+ });
892
906
  });
893
907
  }
894
908
  #withPage(pageIndex, use) {
@@ -169,16 +169,30 @@ function replaceInPlace(context, target, text) {
169
169
  context.withPage(target.location.pageIndex, (page) => {
170
170
  setText(pdfium, lib.FPDFPage_GetObject(page, index), text);
171
171
  });
172
- const readBack = context.withPage(target.location.pageIndex, (page) => {
172
+ const faithful = context.withPage(target.location.pageIndex, (page) => {
173
173
  const textPage = lib.FPDFText_LoadPage(page);
174
174
  try {
175
- return pdfium.readWideString((buffer, bytes) => lib.FPDFTextObj_GetText(lib.FPDFPage_GetObject(page, index), textPage, buffer, bytes));
175
+ const object = lib.FPDFPage_GetObject(page, index);
176
+ const readBack = pdfium.readWideString((buffer, bytes) => lib.FPDFTextObj_GetText(object, textPage, buffer, bytes));
177
+ if (readBack === text)
178
+ return true;
179
+ // Reading-order extraction can append a generated gap before the next
180
+ // object. Verify the object's own characters instead, retaining every
181
+ // authored space: trimming would also hide a lost deliberate space.
182
+ let authored = "";
183
+ const count = lib.FPDFText_CountChars(textPage);
184
+ for (let at = 0; at < count; at += 1) {
185
+ if (lib.FPDFText_GetTextObject(textPage, at) === object &&
186
+ lib.FPDFText_IsGenerated(textPage, at) === 0)
187
+ authored += String.fromCodePoint(lib.FPDFText_GetUnicode(textPage, at));
188
+ }
189
+ return authored === text;
176
190
  }
177
191
  finally {
178
192
  lib.FPDFText_ClosePage(textPage);
179
193
  }
180
194
  });
181
- if (readBack === text)
195
+ if (faithful)
182
196
  return true;
183
197
  // Put the old text back so the fallback path starts from a known state.
184
198
  context.withPage(target.location.pageIndex, (page) => {
@@ -12,6 +12,8 @@ export interface TextPageScan {
12
12
  readonly elements: readonly PdfElement[];
13
13
  /** Per character of the text page: its element and offset, when an object draws it. */
14
14
  readonly offsets: readonly (TextPosition | undefined)[];
15
+ /** Element id → the rectangle its text is laid out in, for text boxes. */
16
+ readonly frames?: ReadonlyMap<string, PageRect>;
15
17
  }
16
18
  /** The layout of one element, or `undefined` when it draws no text. */
17
19
  export declare function layoutOf(pdfium: Pdfium, scan: TextPageScan, element: PdfElement): TextLayout | undefined;
@@ -8,10 +8,12 @@ export function layoutOf(pdfium, scan, element) {
8
8
  if (!TEXT_KINDS.has(element.kind))
9
9
  return undefined;
10
10
  const glyphs = glyphsOf(pdfium, scan, (position) => position.elementId === element.id);
11
+ const lines = linesOf(pdfium, scan, glyphs);
11
12
  return {
12
13
  elementId: element.id,
13
14
  pageIndex: element.pageIndex,
14
- lines: linesOf(pdfium, scan, glyphs),
15
+ frame: frameOf(scan, element, lines),
16
+ lines,
15
17
  };
16
18
  }
17
19
  /** The layouts of every text element of the page, in reading order, from one pass. */
@@ -30,14 +32,30 @@ export function layoutsOf(pdfium, scan) {
30
32
  const own = byElement.get(element.id);
31
33
  if (!own || !TEXT_KINDS.has(element.kind))
32
34
  continue;
35
+ const lines = linesOf(pdfium, scan, own);
33
36
  layouts.push({
34
37
  elementId: element.id,
35
38
  pageIndex: element.pageIndex,
36
- lines: linesOf(pdfium, scan, own),
39
+ frame: frameOf(scan, element, lines),
40
+ lines,
37
41
  });
38
42
  }
39
43
  return layouts;
40
44
  }
45
+ /**
46
+ * The box an element's text is laid out in: a paragraph's frame (its bounds
47
+ * already start at the pen and span its widest advance), a text box's own
48
+ * rect, else the union of the lines' advance boxes.
49
+ */
50
+ function frameOf(scan, element, lines) {
51
+ if (element.kind === "paragraph")
52
+ return element.bounds;
53
+ const own = scan.frames?.get(element.id);
54
+ if (own)
55
+ return own;
56
+ const boxes = lines.flatMap((line) => line.advanceBounds ? [line.advanceBounds] : []);
57
+ return boxes.length > 0 ? roundRect(unionRects(boxes)) : element.bounds;
58
+ }
41
59
  /** The caret position nearest to a page-space point, or none without text. */
42
60
  export function positionIn(pdfium, scan, point) {
43
61
  const glyphs = glyphsOf(pdfium, scan, () => true);
@@ -105,6 +123,7 @@ function glyphsOf(pdfium, scan, keep) {
105
123
  position,
106
124
  char: String.fromCodePoint(lib.FPDFText_GetUnicode(textPage, index)),
107
125
  box: roundRect(userRectToPage(geometry, left, bottom, right, top)),
126
+ loose: roundRect(userRectToPage(geometry, looseLeft, looseBottom, looseRight, looseTop)),
108
127
  advance: round(Math.abs(looseRight - looseLeft)),
109
128
  origin: roundPoint(userToPage(geometry, origin[0], origin[1])),
110
129
  });
@@ -136,11 +155,15 @@ function linesOf(pdfium, scan, glyphs) {
136
155
  },
137
156
  text: members.map((glyph) => glyph.char).join(""),
138
157
  bounds: roundRect(unionRects(members.map((glyph) => glyph.box))),
158
+ // PDFium's loose boxes run from each origin to origin plus advance,
159
+ // ascent to descent: their union is the line a text field must hold.
160
+ advanceBounds: roundRect(unionRects(members.map((glyph) => glyph.loose))),
139
161
  baseline: members[0].origin,
140
- glyphs: members.map(({ position, box, advance }) => ({
162
+ glyphs: members.map(({ position, box, advance, origin }) => ({
141
163
  offset: position.offset,
142
164
  box,
143
165
  advance,
166
+ origin,
144
167
  })),
145
168
  fontFamily: style.fontFamily,
146
169
  fontSize: style.fontSize,
@@ -75,6 +75,7 @@ export interface PdfiumFunctions {
75
75
  FPDFText_GetCharBox(textPage: number, index: number, left: number, right: number, bottom: number, top: number): boolean;
76
76
  FPDFText_GetTextObject(textPage: number, index: number): number;
77
77
  FPDFText_GetUnicode(textPage: number, index: number): number;
78
+ FPDFText_IsGenerated(textPage: number, index: number): number;
78
79
  FPDFText_GetCharIndexFromTextIndex(textPage: number, textIndex: number): number;
79
80
  /** Advance box of a character as an FS_RECTF {left, top, right, bottom} of floats. */
80
81
  FPDFText_GetLooseCharBox(textPage: number, index: number, rect: number): boolean;
@@ -255,6 +255,12 @@ export interface TextLayoutGlyph {
255
255
  readonly box: PageRect;
256
256
  /** Advance width along the baseline, in points. */
257
257
  readonly advance: number;
258
+ /**
259
+ * Pen position on the baseline where the glyph starts, in page space. The
260
+ * steps between neighbours are the advances the file draws with, character
261
+ * and word spacing and TJ kerning included.
262
+ */
263
+ readonly origin?: PagePoint;
258
264
  }
259
265
  /** One line of a text element: one PDFium text object, as the file stores it. */
260
266
  export interface TextLayoutLine {
@@ -263,6 +269,15 @@ export interface TextLayoutLine {
263
269
  readonly text: string;
264
270
  /** Union of the glyph boxes, in page space. */
265
271
  readonly bounds: PageRect;
272
+ /**
273
+ * The box the line's advances fill, the size a text field needs to hold the
274
+ * line unwrapped: from the first glyph's origin to the last glyph's origin
275
+ * plus its advance, and from the font's ascent to its descent at the line's
276
+ * size. It differs from `bounds` by the glyphs' side bearings, usually
277
+ * wider; in page space and axis-aligned like `bounds`, so it turns with the
278
+ * text.
279
+ */
280
+ readonly advanceBounds?: PageRect;
266
281
  /** Start of the baseline, in page space. */
267
282
  readonly baseline: PagePoint;
268
283
  readonly glyphs: readonly TextLayoutGlyph[];
@@ -305,10 +320,17 @@ export interface TextFont {
305
320
  */
306
321
  readonly missing?: "not-embedded" | "cid-keyed" | "type1" | "no-unicode" | "unreadable";
307
322
  }
308
- /** The drawn geometry of a `text`, `textBox` or `table` element. */
323
+ /** The drawn geometry of a `text`, `textBox`, `paragraph` or `table` element. */
309
324
  export interface TextLayout {
310
325
  readonly elementId: string;
311
326
  readonly pageIndex: number;
327
+ /**
328
+ * The box the element's text is laid out in, in page space: a text box's or
329
+ * a paragraph's own rectangle, otherwise the union of its lines'
330
+ * `advanceBounds`. Place an inline text field here rather than on the ink
331
+ * `bounds`, which a browser's advance-based layout outgrows.
332
+ */
333
+ readonly frame?: PageRect;
312
334
  /** Lines in reading order: a text box's lines, a table's cells. */
313
335
  readonly lines: readonly TextLayoutLine[];
314
336
  }
@@ -6759,10 +6759,12 @@ function layoutOf(pdfium, scan, element) {
6759
6759
  scan,
6760
6760
  (position) => position.elementId === element.id
6761
6761
  );
6762
+ const lines = linesOf(pdfium, scan, glyphs);
6762
6763
  return {
6763
6764
  elementId: element.id,
6764
6765
  pageIndex: element.pageIndex,
6765
- lines: linesOf(pdfium, scan, glyphs)
6766
+ frame: frameOf(scan, element, lines),
6767
+ lines
6766
6768
  };
6767
6769
  }
6768
6770
  function layoutsOf(pdfium, scan) {
@@ -6777,14 +6779,25 @@ function layoutsOf(pdfium, scan) {
6777
6779
  for (const element of scan.elements) {
6778
6780
  const own = byElement.get(element.id);
6779
6781
  if (!own || !TEXT_KINDS.has(element.kind)) continue;
6782
+ const lines = linesOf(pdfium, scan, own);
6780
6783
  layouts.push({
6781
6784
  elementId: element.id,
6782
6785
  pageIndex: element.pageIndex,
6783
- lines: linesOf(pdfium, scan, own)
6786
+ frame: frameOf(scan, element, lines),
6787
+ lines
6784
6788
  });
6785
6789
  }
6786
6790
  return layouts;
6787
6791
  }
6792
+ function frameOf(scan, element, lines) {
6793
+ if (element.kind === "paragraph") return element.bounds;
6794
+ const own = scan.frames?.get(element.id);
6795
+ if (own) return own;
6796
+ const boxes = lines.flatMap(
6797
+ (line) => line.advanceBounds ? [line.advanceBounds] : []
6798
+ );
6799
+ return boxes.length > 0 ? roundRect(unionRects(boxes)) : element.bounds;
6800
+ }
6788
6801
  function positionIn(pdfium, scan, point) {
6789
6802
  const glyphs = glyphsOf(pdfium, scan, () => true);
6790
6803
  if (glyphs.length === 0) return void 0;
@@ -6859,6 +6872,9 @@ function glyphsOf(pdfium, scan, keep) {
6859
6872
  position,
6860
6873
  char: String.fromCodePoint(lib.FPDFText_GetUnicode(textPage, index)),
6861
6874
  box: roundRect(userRectToPage(geometry, left, bottom, right, top)),
6875
+ loose: roundRect(
6876
+ userRectToPage(geometry, looseLeft, looseBottom, looseRight, looseTop)
6877
+ ),
6862
6878
  advance: round(Math.abs(looseRight - looseLeft)),
6863
6879
  origin: roundPoint(userToPage(geometry, origin[0], origin[1]))
6864
6880
  });
@@ -6886,11 +6902,15 @@ function linesOf(pdfium, scan, glyphs) {
6886
6902
  },
6887
6903
  text: members.map((glyph) => glyph.char).join(""),
6888
6904
  bounds: roundRect(unionRects(members.map((glyph) => glyph.box))),
6905
+ // PDFium's loose boxes run from each origin to origin plus advance,
6906
+ // ascent to descent: their union is the line a text field must hold.
6907
+ advanceBounds: roundRect(unionRects(members.map((glyph) => glyph.loose))),
6889
6908
  baseline: members[0].origin,
6890
- glyphs: members.map(({ position, box, advance: advance2 }) => ({
6909
+ glyphs: members.map(({ position, box, advance: advance2, origin }) => ({
6891
6910
  offset: position.offset,
6892
6911
  box,
6893
- advance: advance2
6912
+ advance: advance2,
6913
+ origin
6894
6914
  })),
6895
6915
  fontFamily: style.fontFamily,
6896
6916
  fontSize: style.fontSize,
@@ -9997,22 +10017,28 @@ function replaceInPlace(context, target, text) {
9997
10017
  context.withPage(target.location.pageIndex, (page) => {
9998
10018
  setText(pdfium, lib.FPDFPage_GetObject(page, index), text);
9999
10019
  });
10000
- const readBack = context.withPage(target.location.pageIndex, (page) => {
10020
+ const faithful = context.withPage(target.location.pageIndex, (page) => {
10001
10021
  const textPage = lib.FPDFText_LoadPage(page);
10002
10022
  try {
10003
- return pdfium.readWideString(
10004
- (buffer, bytes2) => lib.FPDFTextObj_GetText(
10005
- lib.FPDFPage_GetObject(page, index),
10006
- textPage,
10007
- buffer,
10008
- bytes2
10009
- )
10023
+ const object = lib.FPDFPage_GetObject(page, index);
10024
+ const readBack = pdfium.readWideString(
10025
+ (buffer, bytes2) => lib.FPDFTextObj_GetText(object, textPage, buffer, bytes2)
10010
10026
  );
10027
+ if (readBack === text) return true;
10028
+ let authored = "";
10029
+ const count = lib.FPDFText_CountChars(textPage);
10030
+ for (let at = 0; at < count; at += 1) {
10031
+ if (lib.FPDFText_GetTextObject(textPage, at) === object && lib.FPDFText_IsGenerated(textPage, at) === 0)
10032
+ authored += String.fromCodePoint(
10033
+ lib.FPDFText_GetUnicode(textPage, at)
10034
+ );
10035
+ }
10036
+ return authored === text;
10011
10037
  } finally {
10012
10038
  lib.FPDFText_ClosePage(textPage);
10013
10039
  }
10014
10040
  });
10015
- if (readBack === text) return true;
10041
+ if (faithful) return true;
10016
10042
  context.withPage(target.location.pageIndex, (page) => {
10017
10043
  setText(pdfium, lib.FPDFPage_GetObject(page, index), previous);
10018
10044
  });
@@ -11500,9 +11526,9 @@ var PdfEditDocument = class {
11500
11526
  return this.#pdfium;
11501
11527
  }
11502
11528
  /**
11503
- * Checks a batch against the current document. Operations are checked one
11504
- * after another against the state before the batch, which is exact for
11505
- * everything but a page count another operation of the batch changes.
11529
+ * Preflights a batch against its starting document. Applying revalidates
11530
+ * each operation against earlier changes in the batch; created-element
11531
+ * references are checked once their targets exist.
11506
11532
  */
11507
11533
  validate(operations) {
11508
11534
  const issues = [];
@@ -11558,7 +11584,7 @@ var PdfEditDocument = class {
11558
11584
  if (!handler)
11559
11585
  throw new ViewerError("internal", `Unknown pdf operation ${raw.op}`);
11560
11586
  const context = this.#context(stateId, operationIndex);
11561
- const operation2 = this.#resolveReference(
11587
+ const operation2 = this.#resolveAndValidate(
11562
11588
  raw,
11563
11589
  operationIndex,
11564
11590
  createdByOperation,
@@ -11585,26 +11611,27 @@ var PdfEditDocument = class {
11585
11611
  };
11586
11612
  }
11587
11613
  /**
11588
- * Turns a `"$<n>"` target into the id operation `n` created, then runs the
11589
- * handler's own checks on the resolved operation: a reference that lands
11590
- * on an element the operation cannot act on fails the batch the same way
11591
- * validation would have.
11614
+ * Resolves a `"$<n>"` target, then checks every operation against the
11615
+ * current document. Earlier operations may have moved, resized or removed
11616
+ * an ordinary target since the batch's initial preflight.
11592
11617
  */
11593
- #resolveReference(operation2, operationIndex, createdByOperation, handler, context) {
11618
+ #resolveAndValidate(operation2, operationIndex, createdByOperation, handler, context) {
11594
11619
  const reference = referenceOf(operation2);
11595
- if (reference === void 0) return operation2;
11596
11620
  const issues = [];
11597
11621
  const issue = issueCollector(operationIndex, issues);
11598
- const id = createdByOperation[reference]?.[0];
11599
- if (id === void 0) {
11600
- issue(
11601
- "/target",
11602
- "unknown-target",
11603
- `Operation ${reference} created no element for "$${reference}"`
11604
- );
11605
- throw invalidOperationError(issues);
11622
+ let resolved = operation2;
11623
+ if (reference !== void 0 && "target" in operation2) {
11624
+ const id = createdByOperation[reference]?.[0];
11625
+ if (id === void 0) {
11626
+ issue(
11627
+ "/target",
11628
+ "unknown-target",
11629
+ `Operation ${reference} created no element for "$${reference}"`
11630
+ );
11631
+ throw invalidOperationError(issues);
11632
+ }
11633
+ resolved = { ...operation2, target: id };
11606
11634
  }
11607
- const resolved = { ...operation2, target: id };
11608
11635
  handler.validate(resolved, context, issue);
11609
11636
  if (issues.length > 0) throw invalidOperationError(issues);
11610
11637
  return resolved;
@@ -12238,7 +12265,19 @@ var PdfEditDocument = class {
12238
12265
  byObject,
12239
12266
  new Map(elements.map((element) => [element.id, element.text ?? ""]))
12240
12267
  );
12241
- return use({ page, textPage, geometry, byObject, elements, offsets });
12268
+ const frames = /* @__PURE__ */ new Map();
12269
+ for (const record of records)
12270
+ if (record.mark?.kind === "textBox" && !frames.has(record.id))
12271
+ frames.set(record.id, roundRect(record.mark.rect));
12272
+ return use({
12273
+ page,
12274
+ textPage,
12275
+ geometry,
12276
+ byObject,
12277
+ elements,
12278
+ offsets,
12279
+ frames
12280
+ });
12242
12281
  });
12243
12282
  }
12244
12283
  #withPage(pageIndex, use) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "web-doc",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "web-doc — embeddable browser-only document viewer with Rust/WASM adapters (a fork of Zrimo)",
5
5
  "keywords": [
6
6
  "document-viewer",