web-doc 0.11.0 → 0.12.1

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.
Files changed (35) hide show
  1. package/dist/adapters/pdf.js +19 -8
  2. package/dist/edit/engine.d.ts +2 -1
  3. package/dist/edit/pdf/engine/document.d.ts +4 -2
  4. package/dist/edit/pdf/engine/document.js +115 -12
  5. package/dist/edit/pdf/engine/elements.d.ts +5 -1
  6. package/dist/edit/pdf/engine/elements.js +88 -6
  7. package/dist/edit/pdf/engine/existing-text.js +33 -64
  8. package/dist/edit/pdf/engine/fonts.d.ts +14 -0
  9. package/dist/edit/pdf/engine/fonts.js +114 -0
  10. package/dist/edit/pdf/engine/handler.js +2 -0
  11. package/dist/edit/pdf/engine/layout.js +1 -1
  12. package/dist/edit/pdf/engine/operations.d.ts +7 -1
  13. package/dist/edit/pdf/engine/paragraph-edit.d.ts +4 -0
  14. package/dist/edit/pdf/engine/paragraph-edit.js +331 -0
  15. package/dist/edit/pdf/engine/paragraph.d.ts +31 -0
  16. package/dist/edit/pdf/engine/paragraph.js +445 -0
  17. package/dist/edit/pdf/engine/pdfium.d.ts +4 -0
  18. package/dist/edit/pdf/engine/text-box.js +16 -3
  19. package/dist/edit/pdf/engine/text-font.js +2 -19
  20. package/dist/edit/pdf/engine/transform.js +14 -3
  21. package/dist/edit/pdf/provider.d.ts +3 -1
  22. package/dist/edit/pdf/provider.js +3 -0
  23. package/dist/edit/pdf/range-map.d.ts +2 -1
  24. package/dist/edit/pdf/range-map.js +38 -8
  25. package/dist/edit/pdf/schemas.d.ts +2 -0
  26. package/dist/edit/pdf/schemas.js +37 -0
  27. package/dist/edit/pdf/session.d.ts +3 -1
  28. package/dist/edit/pdf/session.js +35 -6
  29. package/dist/edit/pdf/types.d.ts +38 -8
  30. package/dist/edit/session.js +5 -0
  31. package/dist/edit/types.d.ts +13 -0
  32. package/dist/viewport.js +11 -0
  33. package/dist/worker-protocol.d.ts +1 -1
  34. package/dist/workers/pdf-edit-worker.js +2026 -1004
  35. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
+ import { paragraphSetTextStyle, replaceParagraphText, } from "./paragraph-edit.js";
1
2
  import { objectBounds, OBJECT_TEXT, textScale } from "./elements.js";
2
- import { firstNonWinAnsi, isStandardFamily, parseCmap, } from "./fonts.js";
3
+ import { fontCanRewrite, validateScript } from "./fonts.js";
3
4
  import { pageToUser } from "./geometry.js";
4
5
  import { parseColor, setText, textBoxReplaceText, textBoxSetTextStyle, textBoxTarget, } from "./text-box.js";
5
6
  /*
@@ -10,6 +11,8 @@ import { parseColor, setText, textBoxReplaceText, textBoxSetTextStyle, textBoxTa
10
11
  */
11
12
  export const replaceText = {
12
13
  validate(operation, context, issue) {
14
+ if (context.paragraph(operation.target)?.paragraph.id === operation.target)
15
+ return replaceParagraphText.validate({ ...operation, op: "replaceParagraphText" }, context, issue);
13
16
  const box = textBoxTarget(operation.target, context);
14
17
  if (box) {
15
18
  const spliced = splice(box.spec.text, operation, issue);
@@ -42,6 +45,8 @@ export const replaceText = {
42
45
  issue(problem.path, problem.code, problem.message);
43
46
  },
44
47
  apply(operation, context) {
48
+ if (context.paragraph(operation.target)?.paragraph.id === operation.target)
49
+ return replaceParagraphText.apply({ ...operation, op: "replaceParagraphText" }, context);
45
50
  const box = textBoxTarget(operation.target, context);
46
51
  if (box)
47
52
  return textBoxReplaceText.apply(wholeTextOperation(operation, splice(box.spec.text, operation)), context);
@@ -95,6 +100,8 @@ function spanOf(operation, previous) {
95
100
  }
96
101
  export const setTextStyle = {
97
102
  validate(operation, context, issue) {
103
+ if (context.paragraph(operation.target)?.paragraph.id === operation.target)
104
+ return paragraphSetTextStyle.validate(operation, context, issue);
98
105
  if (textBoxTarget(operation.target, context))
99
106
  return textBoxSetTextStyle.validate(operation, context, issue);
100
107
  if (!textTarget(operation.target, context, issue))
@@ -110,6 +117,8 @@ export const setTextStyle = {
110
117
  issue(`/style/${field}`, "unsupported-style", `Only color and fontSize can change on existing text; ${field} cannot`);
111
118
  },
112
119
  apply(operation, context) {
120
+ if (context.paragraph(operation.target)?.paragraph.id === operation.target)
121
+ return paragraphSetTextStyle.apply(operation, context);
113
122
  if (textBoxTarget(operation.target, context))
114
123
  return textBoxSetTextStyle.apply(operation, context);
115
124
  const target = textTarget(operation.target, context);
@@ -139,72 +148,18 @@ function textTarget(target, context, issue = () => { }) {
139
148
  }
140
149
  return { location, element };
141
150
  }
142
- /** Right-to-left scripts need shaping the MVP does not do. */
143
- function validateScript(text, issue) {
144
- for (const character of text) {
145
- const code = character.codePointAt(0);
146
- if ((code >= 0x0590 && code <= 0x08ff) ||
147
- (code >= 0xfb1d && code <= 0xfdff) ||
148
- (code >= 0xfe70 && code <= 0xfeff)) {
149
- issue("/text", "unsupported-script", `Right-to-left text such as "${character}" cannot be edited yet`);
150
- return false;
151
- }
152
- }
153
- return true;
154
- }
155
151
  /**
156
152
  * Whether the object's own font can draw `text`: a standard font for WinAnsi
157
- * text, or an embedded TrueType program whose cmap covers every character
158
- * (which also answers for subsets, since their cmaps hold only what they
159
- * kept).
153
+ * text, or an embedded TrueType or bare CFF program with a glyph for every
154
+ * character (which also answers for subsets, since they hold only what they
155
+ * kept), each with a width in the PDF.
160
156
  */
161
157
  function canKeepFont(context, target, text) {
162
- const { pdfium } = context;
163
- const { lib } = pdfium;
164
- return context.withPage(target.location.pageIndex, (page) => {
165
- const object = lib.FPDFPage_GetObject(page, target.location.indexes[0]);
166
- const font = lib.FPDFTextObj_GetFont(object);
167
- if (!lib.FPDFFont_GetIsEmbedded(font)) {
168
- const base = pdfium.readUtf8String((buffer, bytes) => lib.FPDFFont_GetBaseFontName(font, buffer, bytes));
169
- return (isStandardFamily(base.split(/[-,]/)[0] ?? "") &&
170
- firstNonWinAnsi(text) === undefined);
171
- }
172
- const coverage = fontCoverage(pdfium, font);
173
- if (!coverage)
174
- return false;
175
- for (const character of text) {
176
- const code = character.codePointAt(0);
177
- if (code !== 0x0a && code !== 0x20 && !coverage.drawable(code))
178
- return false;
179
- }
180
- return true;
158
+ return context.readPage(target.location.pageIndex, (page) => {
159
+ const object = context.pdfium.lib.FPDFPage_GetObject(page, target.location.indexes[0]);
160
+ return fontCanRewrite(context.pdfium, context.pdfium.lib.FPDFTextObj_GetFont(object), text);
181
161
  });
182
162
  }
183
- function fontCoverage(pdfium, font) {
184
- const { lib } = pdfium;
185
- const size = pdfium.readNumbers(1, "i32", ([out]) => lib.FPDFFont_GetFontData(font, 0, 0, out))?.[0];
186
- if (!size)
187
- return undefined;
188
- const buffer = pdfium.malloc(size);
189
- try {
190
- const out = pdfium.malloc(4);
191
- try {
192
- if (!lib.FPDFFont_GetFontData(font, buffer, size, out))
193
- return undefined;
194
- }
195
- finally {
196
- pdfium.free(out);
197
- }
198
- const data = pdfium.readBytes(buffer, size);
199
- const tag = String.fromCharCode(data[0], data[1], data[2], data[3]);
200
- const truetype = (data[0] === 0 && data[1] === 1 && data[2] === 0 && data[3] === 0) ||
201
- tag === "true";
202
- return truetype ? parseCmap(data) : undefined;
203
- }
204
- finally {
205
- pdfium.free(buffer);
206
- }
207
- }
208
163
  /** Sets the text and confirms PDFium reads it back; false means it must be replaced. */
209
164
  function replaceInPlace(context, target, text) {
210
165
  const { pdfium } = context;
@@ -214,16 +169,30 @@ function replaceInPlace(context, target, text) {
214
169
  context.withPage(target.location.pageIndex, (page) => {
215
170
  setText(pdfium, lib.FPDFPage_GetObject(page, index), text);
216
171
  });
217
- const readBack = context.withPage(target.location.pageIndex, (page) => {
172
+ const faithful = context.withPage(target.location.pageIndex, (page) => {
218
173
  const textPage = lib.FPDFText_LoadPage(page);
219
174
  try {
220
- 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;
221
190
  }
222
191
  finally {
223
192
  lib.FPDFText_ClosePage(textPage);
224
193
  }
225
194
  });
226
- if (readBack === text)
195
+ if (faithful)
227
196
  return true;
228
197
  // Put the old text back so the fallback path starts from a known state.
229
198
  context.withPage(target.location.pageIndex, (page) => {
@@ -1,4 +1,5 @@
1
1
  import type { EditWorkerFont } from "../../../worker-protocol.js";
2
+ import type { Issue } from "./operations.js";
2
3
  import type { Pdfium } from "./pdfium.js";
3
4
  export declare const FALLBACK_FAMILY = "Noto Sans";
4
5
  export declare function isStandardFamily(family: string): boolean;
@@ -76,3 +77,16 @@ export declare class TextMeasurer {
76
77
  descent: number;
77
78
  };
78
79
  }
80
+ /** True only when the original native font is known to encode every character. */
81
+ export declare function fontCanDraw(pdfium: Pdfium, font: number, text: string): boolean;
82
+ /**
83
+ * Whether one text object can be rewritten in its own font: what
84
+ * `fontCanDraw` proves, extended to bare CFF subsets, whose glyph names say
85
+ * what they draw, and narrowed to characters the PDF gives an advance, since
86
+ * a subset's widths list only the codes it used. Paragraph reflow keeps to
87
+ * `fontCanDraw`, which also decides which rows join a paragraph.
88
+ */
89
+ export declare function fontCanRewrite(pdfium: Pdfium, font: number, text: string): boolean;
90
+ export declare function fontBytes(pdfium: Pdfium, font: number): Uint8Array | undefined;
91
+ /** Right-to-left scripts need shaping the MVP does not do. */
92
+ export declare function validateScript(text: string, issue: Issue): boolean;
@@ -1,3 +1,4 @@
1
+ import { glyphUnicode, readCff } from "./cff.js";
1
2
  /*
2
3
  * Fonts for text web-doc writes into a PDF: the standard 14 fonts for
3
4
  * WinAnsi text, host-registered TrueType fonts, and a bundled Noto Sans
@@ -467,3 +468,116 @@ export class TextMeasurer {
467
468
  }
468
469
  }
469
470
  }
471
+ /** True only when the original native font is known to encode every character. */
472
+ export function fontCanDraw(pdfium, font, text) {
473
+ const { lib } = pdfium;
474
+ if (!lib.FPDFFont_GetIsEmbedded(font)) {
475
+ const base = pdfium.readUtf8String((buffer, bytes) => lib.FPDFFont_GetBaseFontName(font, buffer, bytes));
476
+ return (isStandardFamily(base.split(/[-,]/)[0] ?? "") &&
477
+ firstNonWinAnsi(text) === undefined);
478
+ }
479
+ const coverage = fontCoverage(pdfium, font);
480
+ if (!coverage)
481
+ return false;
482
+ return [...text].every((character) => {
483
+ const code = character.codePointAt(0);
484
+ // A space needs an encoding even though a valid space has no outline.
485
+ // Imported subsets can omit its cmap entry and use positioned word gaps.
486
+ if (code === 0x20)
487
+ return (coverage.glyph(code) ?? 0) !== 0;
488
+ return code === 0x0a || coverage.drawable(code);
489
+ });
490
+ }
491
+ /**
492
+ * Whether one text object can be rewritten in its own font: what
493
+ * `fontCanDraw` proves, extended to bare CFF subsets, whose glyph names say
494
+ * what they draw, and narrowed to characters the PDF gives an advance, since
495
+ * a subset's widths list only the codes it used. Paragraph reflow keeps to
496
+ * `fontCanDraw`, which also decides which rows join a paragraph.
497
+ */
498
+ export function fontCanRewrite(pdfium, font, text) {
499
+ const characters = [...text].filter((character) => character !== "\n");
500
+ if (!characters.every((character) => advance(pdfium, font, character.codePointAt(0)) > 0))
501
+ return false;
502
+ if (fontCanDraw(pdfium, font, text))
503
+ return true;
504
+ if (!pdfium.lib.FPDFFont_GetIsEmbedded(font))
505
+ return false;
506
+ const data = fontBytes(pdfium, font);
507
+ const coverage = data && cffCoverage(data);
508
+ if (!coverage)
509
+ return false;
510
+ // A space needs a glyph of its own, as in `fontCanDraw`.
511
+ return characters.every((character) => coverage.has(character.codePointAt(0)));
512
+ }
513
+ /** The PDF's width for a character, per 1000 units of size; 0 for one it gives none or PDFium cannot ask about. */
514
+ function advance(pdfium, font, code) {
515
+ // PDFium takes one UTF-16 code unit.
516
+ if (code > 0xffff)
517
+ return 0;
518
+ return (pdfium.readNumbers(1, "float", ([width]) => pdfium.lib.FPDFFont_GetGlyphWidth(font, code, 1000, width))?.[0] ?? 0);
519
+ }
520
+ /**
521
+ * A bare CFF program's glyphs by the code points their names stand for, as a
522
+ * simple font draws them: its encoding names the glyph. A CID-keyed program
523
+ * names none, and only its PDF CMap would say what its glyphs draw.
524
+ */
525
+ function cffCoverage(data) {
526
+ const cff = readCff(data);
527
+ if (!cff || cff.cid)
528
+ return undefined;
529
+ const glyphs = new Map();
530
+ cff.glyphNames.forEach((name, glyph) => {
531
+ const code = glyphUnicode(name);
532
+ if (glyph !== 0 && code !== undefined && !glyphs.has(code))
533
+ glyphs.set(code, glyph);
534
+ });
535
+ return {
536
+ has: (code) => glyphs.has(code),
537
+ glyph: (code) => glyphs.get(code),
538
+ drawable: (code) => glyphs.has(code),
539
+ };
540
+ }
541
+ function fontCoverage(pdfium, font) {
542
+ const data = fontBytes(pdfium, font);
543
+ if (!data)
544
+ return undefined;
545
+ const tag = String.fromCharCode(...data.slice(0, 4));
546
+ const truetype = (data[0] === 0 && data[1] === 1 && data[2] === 0 && data[3] === 0) ||
547
+ tag === "true";
548
+ return truetype ? parseCmap(data) : undefined;
549
+ }
550
+ export function fontBytes(pdfium, font) {
551
+ const { lib } = pdfium;
552
+ const size = pdfium.readNumbers(1, "i32", ([out]) => lib.FPDFFont_GetFontData(font, 0, 0, out))?.[0];
553
+ if (!size)
554
+ return undefined;
555
+ const buffer = pdfium.malloc(size);
556
+ try {
557
+ const out = pdfium.malloc(4);
558
+ try {
559
+ if (!lib.FPDFFont_GetFontData(font, buffer, size, out))
560
+ return undefined;
561
+ }
562
+ finally {
563
+ pdfium.free(out);
564
+ }
565
+ return pdfium.readBytes(buffer, size);
566
+ }
567
+ finally {
568
+ pdfium.free(buffer);
569
+ }
570
+ }
571
+ /** Right-to-left scripts need shaping the MVP does not do. */
572
+ export function validateScript(text, issue) {
573
+ for (const character of text) {
574
+ const code = character.codePointAt(0);
575
+ if ((code >= 0x0590 && code <= 0x08ff) ||
576
+ (code >= 0xfb1d && code <= 0xfdff) ||
577
+ (code >= 0xfe70 && code <= 0xfeff)) {
578
+ issue("/text", "unsupported-script", `Right-to-left text such as "${character}" cannot be edited yet`);
579
+ return false;
580
+ }
581
+ }
582
+ return true;
583
+ }
@@ -84,6 +84,8 @@ export function createPdfEditHandler(host) {
84
84
  }
85
85
  case "edit-text-layout":
86
86
  return engine().textLayout(payload.id);
87
+ case "edit-text-paragraph":
88
+ return engine().textParagraph(payload.id);
87
89
  case "edit-position-at": {
88
90
  const { pageIndex, point } = payload;
89
91
  return engine().positionAt(pageIndex, point);
@@ -2,7 +2,7 @@ import { textStyle } from "./elements.js";
2
2
  import { pageToUser, rectContains, round, roundRect, unionRects, userRectToPage, userToPage, } from "./geometry.js";
3
3
  /** How far a point may be from a glyph for `FPDFText_GetCharIndexAtPos`. */
4
4
  const HIT_TOLERANCE = 2;
5
- const TEXT_KINDS = new Set(["text", "textBox", "table"]);
5
+ const TEXT_KINDS = new Set(["text", "textBox", "table", "paragraph"]);
6
6
  /** The layout of one element, or `undefined` when it draws no text. */
7
7
  export function layoutOf(pdfium, scan, element) {
8
8
  if (!TEXT_KINDS.has(element.kind))
@@ -1,12 +1,13 @@
1
1
  import type { ResourceLimits, ViewerWarning } from "../../../contracts.js";
2
2
  import type { AssetSource } from "../../assets.js";
3
3
  import type { ImageCache } from "./images.js";
4
- import type { OperationIssue } from "../../types.js";
4
+ import type { OperationIssue, TextAnchorMigration } from "../../types.js";
5
5
  import type { PdfElement, PdfOperation } from "../types.js";
6
6
  import type { PageGeometry } from "./geometry.js";
7
7
  import type { ObjectRecord } from "./elements.js";
8
8
  import type { Pdfium } from "./pdfium.js";
9
9
  import type { FontLibrary, TextMeasurer } from "./fonts.js";
10
+ import type { ParagraphTarget } from "./paragraph.js";
10
11
  /** What an operation sees of the document while validating or applying. */
11
12
  export interface OperationContext {
12
13
  readonly pdfium: Pdfium;
@@ -24,12 +25,16 @@ export interface OperationContext {
24
25
  newId(pageIndex: number, suffix?: string): string;
25
26
  /** Loads a page for writing; its content is regenerated afterwards. */
26
27
  withPage<T>(pageIndex: number, use: (page: number) => T): T;
28
+ /** Reads native resources without regenerating the content stream. */
29
+ readPage<T>(pageIndex: number, use: (page: number) => T): T;
27
30
  /** Records objects appended to a page by this operation. */
28
31
  appendObjects(pageIndex: number, records: readonly ObjectRecord[]): void;
29
32
  /** Where an element's objects sit: their page and their indexes in drawing order. */
30
33
  locate(id: string): ElementLocation | undefined;
31
34
  /** The element as a query would return it. */
32
35
  element(id: string): PdfElement | undefined;
36
+ paragraph(id: string): ParagraphTarget | undefined;
37
+ pageElements(pageIndex: number): readonly PdfElement[];
33
38
  /** Ids of every element on a page, for what a page deletion removes. */
34
39
  pageElementIds(pageIndex: number): readonly string[];
35
40
  /** Displayed size of a page in points. */
@@ -56,6 +61,7 @@ export interface OperationResult {
56
61
  readonly createdIds: readonly string[];
57
62
  /** Ids the operation made disappear. */
58
63
  readonly removedIds?: readonly string[];
64
+ readonly textAnchorMigrations?: readonly Omit<TextAnchorMigration, "operationIndex">[];
59
65
  readonly changedPages: readonly number[];
60
66
  readonly warnings: readonly ViewerWarning[];
61
67
  }
@@ -0,0 +1,4 @@
1
+ import type { ReplaceParagraphTextOperation, SetTextStyleOperation } from "../types.js";
2
+ import type { OperationHandler } from "./operations.js";
3
+ export declare const replaceParagraphText: OperationHandler<ReplaceParagraphTextOperation>;
4
+ export declare const paragraphSetTextStyle: OperationHandler<SetTextStyleOperation>;