web-doc 0.11.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/pdf.js +19 -8
- 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/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/pdf-edit-worker.js +2019 -1003
- package/package.json +1 -1
|
@@ -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>;
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { ViewerError } from "../../../errors.js";
|
|
2
|
+
import { MARK_NAME, MARK_PARAM, OBJECT_PATH, OBJECT_TEXT } from "./elements.js";
|
|
3
|
+
import { fontCanDraw, isStandardFamily, validateScript } from "./fonts.js";
|
|
4
|
+
import { pageToUser, round } from "./geometry.js";
|
|
5
|
+
import { layoutText } from "./text-layout.js";
|
|
6
|
+
import { parseColor, placeUpright, setText } from "./text-box.js";
|
|
7
|
+
import { intersects, } from "./paragraph.js";
|
|
8
|
+
export const replaceParagraphText = {
|
|
9
|
+
validate(operation, context, issue) {
|
|
10
|
+
const target = targetOf(operation.target, context, issue);
|
|
11
|
+
if (!target)
|
|
12
|
+
return;
|
|
13
|
+
const text = replacement(operation, target.spec.text, issue);
|
|
14
|
+
if (text === undefined || !validateScript(text, issue))
|
|
15
|
+
return;
|
|
16
|
+
if (text.length > 20000) {
|
|
17
|
+
issue("/text", "max-length", "The complete paragraph cannot exceed 20000 characters");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
prepare(context, target, { ...target.spec, text }, issue);
|
|
21
|
+
},
|
|
22
|
+
apply(operation, context) {
|
|
23
|
+
const target = requireTarget(operation.target, context);
|
|
24
|
+
const text = replacement(operation, target.spec.text);
|
|
25
|
+
if (text === undefined)
|
|
26
|
+
throw new ViewerError("invalid-operation", "Invalid paragraph range");
|
|
27
|
+
return rebuild(context, target, { ...target.spec, text });
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
export const paragraphSetTextStyle = {
|
|
31
|
+
validate(operation, context, issue) {
|
|
32
|
+
const target = targetOf(operation.target, context, issue);
|
|
33
|
+
if (!target)
|
|
34
|
+
return;
|
|
35
|
+
for (const field of [
|
|
36
|
+
"fontFamily",
|
|
37
|
+
"bold",
|
|
38
|
+
"italic",
|
|
39
|
+
"align",
|
|
40
|
+
"lineHeight",
|
|
41
|
+
])
|
|
42
|
+
if (operation.style[field] !== undefined)
|
|
43
|
+
issue(`/style/${field}`, "unsupported-style", `Only color and fontSize can change on imported paragraphs; ${field} cannot`);
|
|
44
|
+
const spec = styled(target.spec, operation);
|
|
45
|
+
if (spec.style.fontSize !== target.spec.style.fontSize)
|
|
46
|
+
prepare(context, target, spec, issue);
|
|
47
|
+
},
|
|
48
|
+
apply(operation, context) {
|
|
49
|
+
const target = requireTarget(operation.target, context);
|
|
50
|
+
const spec = styled(target.spec, operation);
|
|
51
|
+
if (spec.style.fontSize === target.spec.style.fontSize)
|
|
52
|
+
return colorInPlace(context, target, spec);
|
|
53
|
+
return rebuild(context, target, spec);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
/** Color never rewrites glyphs or changes an imported font resource. */
|
|
57
|
+
function colorInPlace(context, target, spec) {
|
|
58
|
+
const { pdfium } = context;
|
|
59
|
+
const { lib } = pdfium;
|
|
60
|
+
const color = parseColor(spec.style.color);
|
|
61
|
+
const persisted = target.paragraph.memberIds.includes(spec.id);
|
|
62
|
+
context.withPage(target.paragraph.pageIndex, (page) => {
|
|
63
|
+
for (const index of target.indexes) {
|
|
64
|
+
const object = lib.FPDFPage_GetObject(page, index);
|
|
65
|
+
if (lib.FPDFPageObj_GetType(object) === OBJECT_TEXT)
|
|
66
|
+
lib.FPDFPageObj_SetFillColor(object, ...color, 255);
|
|
67
|
+
if (!persisted)
|
|
68
|
+
continue;
|
|
69
|
+
for (let at = 0; at < lib.FPDFPageObj_CountMarks(object); at += 1) {
|
|
70
|
+
const mark = lib.FPDFPageObj_GetMark(object, at);
|
|
71
|
+
const name = pdfium.readWideStringOut((buffer, bytes, out) => lib.FPDFPageObjMark_GetName(mark, buffer, bytes, out));
|
|
72
|
+
if (name === MARK_NAME)
|
|
73
|
+
lib.FPDFPageObjMark_SetStringParam(context.document, object, mark, MARK_PARAM, JSON.stringify(spec));
|
|
74
|
+
}
|
|
75
|
+
context.spliceObjects(target.paragraph.pageIndex, index, 1, [
|
|
76
|
+
{ id: spec.id, type: lib.FPDFPageObj_GetType(object), mark: spec },
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
createdIds: [],
|
|
82
|
+
changedPages: [target.paragraph.pageIndex],
|
|
83
|
+
warnings: [],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function styled(spec, operation) {
|
|
87
|
+
return {
|
|
88
|
+
...spec,
|
|
89
|
+
style: {
|
|
90
|
+
...spec.style,
|
|
91
|
+
...(operation.style.fontSize === undefined
|
|
92
|
+
? {}
|
|
93
|
+
: { fontSize: round(operation.style.fontSize) }),
|
|
94
|
+
...(operation.style.color === undefined
|
|
95
|
+
? {}
|
|
96
|
+
: { color: operation.style.color }),
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function targetOf(id, context, issue) {
|
|
101
|
+
const target = context.paragraph(id);
|
|
102
|
+
if (!target || target.paragraph.id !== id) {
|
|
103
|
+
issue("/target", "unsupported-target", "The target must be a resolved native paragraph id");
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
return target;
|
|
107
|
+
}
|
|
108
|
+
function requireTarget(id, context) {
|
|
109
|
+
const target = targetOf(id, context, () => { });
|
|
110
|
+
if (!target)
|
|
111
|
+
throw new ViewerError("invalid-operation", "The paragraph no longer exists");
|
|
112
|
+
return target;
|
|
113
|
+
}
|
|
114
|
+
function replacement(operation, previous, issue = () => { }) {
|
|
115
|
+
const range = operation.range;
|
|
116
|
+
if (!range)
|
|
117
|
+
return operation.text;
|
|
118
|
+
const valid = [range.start, range.end].every((position) => position.elementId === operation.target &&
|
|
119
|
+
Number.isInteger(position.offset) &&
|
|
120
|
+
position.offset >= 0 &&
|
|
121
|
+
position.offset <= previous.length);
|
|
122
|
+
if (!valid || range.start.offset > range.end.offset) {
|
|
123
|
+
issue("/range", "invalid-range", "The range must lie inside the paragraph's logical text");
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
return (previous.slice(0, range.start.offset) +
|
|
127
|
+
operation.text +
|
|
128
|
+
previous.slice(range.end.offset));
|
|
129
|
+
}
|
|
130
|
+
function fontFor(context, target, spec, page) {
|
|
131
|
+
const { lib } = context.pdfium;
|
|
132
|
+
const original = target.indexes
|
|
133
|
+
.map((index) => lib.FPDFPage_GetObject(page, index))
|
|
134
|
+
.find((object) => lib.FPDFPageObj_GetType(object) === OBJECT_TEXT);
|
|
135
|
+
const native = original === undefined ? undefined : lib.FPDFTextObj_GetFont(original);
|
|
136
|
+
if (native !== undefined && fontCanDraw(context.pdfium, native, spec.text))
|
|
137
|
+
return {
|
|
138
|
+
handle: native,
|
|
139
|
+
family: spec.style.fontFamily,
|
|
140
|
+
substituted: false,
|
|
141
|
+
};
|
|
142
|
+
const family = isStandardFamily(spec.style.fontFamily) ||
|
|
143
|
+
context.fonts.hasFamily(spec.style.fontFamily)
|
|
144
|
+
? spec.style.fontFamily
|
|
145
|
+
: "Helvetica";
|
|
146
|
+
const request = {
|
|
147
|
+
family,
|
|
148
|
+
bold: spec.style.bold,
|
|
149
|
+
italic: spec.style.italic,
|
|
150
|
+
text: spec.text,
|
|
151
|
+
};
|
|
152
|
+
const problem = context.fonts.problem(request);
|
|
153
|
+
if (problem)
|
|
154
|
+
return { problem };
|
|
155
|
+
const font = context.fonts.resolve(context.pdfium, context.document, request);
|
|
156
|
+
return {
|
|
157
|
+
handle: font.handle,
|
|
158
|
+
family: font.family,
|
|
159
|
+
substituted: original !== undefined || !isStandardFamily(spec.style.fontFamily),
|
|
160
|
+
...(font.substitution ? { substitution: font.substitution } : {}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** Validate the actual final baseline/descent, not line-count × leading. */
|
|
164
|
+
function prepare(context, target, spec, issue) {
|
|
165
|
+
return context.readPage(target.paragraph.pageIndex, (page) => {
|
|
166
|
+
const font = fontFor(context, target, spec, page);
|
|
167
|
+
if ("problem" in font) {
|
|
168
|
+
issue(font.problem.path, font.problem.code, font.problem.message);
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
const metrics = context.measurer.metrics(font.handle, spec.style.fontSize);
|
|
172
|
+
const layout = layoutText({
|
|
173
|
+
text: spec.text,
|
|
174
|
+
width: spec.rect.width,
|
|
175
|
+
height: Number.POSITIVE_INFINITY,
|
|
176
|
+
fontSize: spec.style.fontSize,
|
|
177
|
+
lineHeight: spec.style.lineHeight,
|
|
178
|
+
align: "left",
|
|
179
|
+
ascent: spec.baselineOffset * spec.style.fontSize,
|
|
180
|
+
advance: (text) => context.measurer.advance(font.handle, spec.style.fontSize, text),
|
|
181
|
+
});
|
|
182
|
+
const last = layout.lines.at(-1);
|
|
183
|
+
const height = spec.text.trim() && last
|
|
184
|
+
? last.baseline + metrics.descent
|
|
185
|
+
: spec.rect.height;
|
|
186
|
+
const rect = { ...spec.rect, height: Math.max(height, 0.001) };
|
|
187
|
+
const size = context.pageSize(target.paragraph.pageIndex);
|
|
188
|
+
const members = new Set(target.paragraph.memberIds);
|
|
189
|
+
const collision = context
|
|
190
|
+
.pageElements(target.paragraph.pageIndex)
|
|
191
|
+
.some((element) => {
|
|
192
|
+
if (members.has(element.id) || !intersects(rect, element.bounds))
|
|
193
|
+
return false;
|
|
194
|
+
// An already-present background stays in its own drawing position.
|
|
195
|
+
const old = target.spec.rect;
|
|
196
|
+
const b = element.bounds;
|
|
197
|
+
const location = context.locate(element.id);
|
|
198
|
+
const behind = location &&
|
|
199
|
+
location.indexes.every((index) => index < target.indexes[0]);
|
|
200
|
+
return !(element.text === undefined &&
|
|
201
|
+
behind &&
|
|
202
|
+
b.x <= old.x &&
|
|
203
|
+
b.y <= old.y &&
|
|
204
|
+
b.x + b.width >= rect.x + rect.width &&
|
|
205
|
+
b.y + b.height >= rect.y + rect.height);
|
|
206
|
+
});
|
|
207
|
+
if (collision ||
|
|
208
|
+
rect.x < 0 ||
|
|
209
|
+
rect.y < 0 ||
|
|
210
|
+
rect.x + rect.width > size.width + 0.01 ||
|
|
211
|
+
rect.y + rect.height > size.height + 0.01) {
|
|
212
|
+
issue("/text", "paragraph-overflow", "The paragraph would overlap neighboring content or leave the page");
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
const warnings = font.substituted
|
|
216
|
+
? [
|
|
217
|
+
{
|
|
218
|
+
code: "font-substitution",
|
|
219
|
+
message: "substitution" in font && font.substitution
|
|
220
|
+
? font.substitution
|
|
221
|
+
: `${spec.style.fontFamily} cannot draw the new paragraph; ${font.family} is used`,
|
|
222
|
+
details: { elementId: spec.id },
|
|
223
|
+
},
|
|
224
|
+
]
|
|
225
|
+
: [];
|
|
226
|
+
return {
|
|
227
|
+
spec: {
|
|
228
|
+
...spec,
|
|
229
|
+
rect,
|
|
230
|
+
lines: layout.lines.map((line) => line.text).filter(Boolean),
|
|
231
|
+
style: {
|
|
232
|
+
...spec.style,
|
|
233
|
+
fontFamily: font.substituted ? font.family : spec.style.fontFamily,
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
layout,
|
|
237
|
+
warnings,
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
function rebuild(context, target, spec) {
|
|
242
|
+
const issues = [];
|
|
243
|
+
const prepared = prepare(context, target, spec, (path, code, message) => issues.push({ path, code, message }));
|
|
244
|
+
if (!prepared)
|
|
245
|
+
throw new ViewerError("invalid-operation", "The paragraph cannot be changed", { details: { issues } });
|
|
246
|
+
const { pdfium } = context;
|
|
247
|
+
const { lib } = pdfium;
|
|
248
|
+
const pageIndex = target.paragraph.pageIndex;
|
|
249
|
+
const geometry = context.geometry(pageIndex);
|
|
250
|
+
const first = target.indexes[0];
|
|
251
|
+
context.withPage(pageIndex, (page) => {
|
|
252
|
+
const font = fontFor(context, target, spec, page);
|
|
253
|
+
if ("problem" in font)
|
|
254
|
+
throw new ViewerError("invalid-operation", font.problem.message);
|
|
255
|
+
const objects = [];
|
|
256
|
+
const [r, g, b] = parseColor(spec.style.color);
|
|
257
|
+
// Construct before removing borrowed font owners. Empty drafts use a
|
|
258
|
+
// non-painting native path, so no placeholder character leaks into text.
|
|
259
|
+
if (!spec.text.trim()) {
|
|
260
|
+
const rect = prepared.spec.rect;
|
|
261
|
+
const origin = pageToUser(geometry, rect.x, rect.y + rect.height);
|
|
262
|
+
const object = lib.FPDFPageObj_CreateNewRect(origin.x, origin.y, rect.width, rect.height);
|
|
263
|
+
// PDFium discards a path ending in `n` when reopening. A zero-alpha
|
|
264
|
+
// fill preserves the native marked object without painting any pixels.
|
|
265
|
+
lib.FPDFPageObj_SetFillColor(object, 0, 0, 0, 0);
|
|
266
|
+
lib.FPDFPath_SetDrawMode(object, 1, false);
|
|
267
|
+
objects.push({ object, type: OBJECT_PATH });
|
|
268
|
+
}
|
|
269
|
+
else
|
|
270
|
+
for (const line of prepared.layout.lines) {
|
|
271
|
+
if (!line.text)
|
|
272
|
+
continue;
|
|
273
|
+
const object = lib.FPDFPageObj_CreateTextObj(context.document, font.handle, spec.style.fontSize);
|
|
274
|
+
if (!object) {
|
|
275
|
+
for (const made of objects)
|
|
276
|
+
lib.FPDFPageObj_Destroy(made.object);
|
|
277
|
+
throw new ViewerError("edit-failed", "PDFium could not create paragraph text");
|
|
278
|
+
}
|
|
279
|
+
setText(pdfium, object, line.text);
|
|
280
|
+
lib.FPDFPageObj_SetFillColor(object, r, g, b, 255);
|
|
281
|
+
placeUpright(pdfium, object, geometry, spec.rect.x + line.x, spec.rect.y + line.baseline);
|
|
282
|
+
objects.push({ object, type: OBJECT_TEXT });
|
|
283
|
+
}
|
|
284
|
+
const params = JSON.stringify(prepared.spec);
|
|
285
|
+
for (const { object } of objects) {
|
|
286
|
+
const mark = lib.FPDFPageObj_AddMark(object, MARK_NAME);
|
|
287
|
+
lib.FPDFPageObjMark_SetStringParam(context.document, object, mark, MARK_PARAM, params);
|
|
288
|
+
}
|
|
289
|
+
for (const index of [...target.indexes].reverse()) {
|
|
290
|
+
const object = lib.FPDFPage_GetObject(page, index);
|
|
291
|
+
lib.FPDFPage_RemoveObject(page, object);
|
|
292
|
+
lib.FPDFPageObj_Destroy(object);
|
|
293
|
+
context.spliceObjects(pageIndex, index, 1, []);
|
|
294
|
+
}
|
|
295
|
+
objects.forEach(({ object }, at) => lib.FPDFPage_InsertObjectAtIndex(page, object, first + at));
|
|
296
|
+
const textPage = lib.FPDFText_LoadPage(page);
|
|
297
|
+
try {
|
|
298
|
+
const drawn = objects
|
|
299
|
+
.filter(({ type }) => type === OBJECT_TEXT)
|
|
300
|
+
.map(({ object }) => pdfium
|
|
301
|
+
.readWideString((buffer, bytes) => lib.FPDFTextObj_GetText(object, textPage, buffer, bytes))
|
|
302
|
+
.trimEnd());
|
|
303
|
+
if (drawn.length !== prepared.spec.lines.length ||
|
|
304
|
+
drawn.some((line, index) => line !== prepared.spec.lines[index]))
|
|
305
|
+
throw new ViewerError("edit-failed", "The native font could not encode the paragraph faithfully");
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
lib.FPDFText_ClosePage(textPage);
|
|
309
|
+
}
|
|
310
|
+
context.spliceObjects(pageIndex, first, 0, objects.map(({ type }) => ({ id: spec.id, type, mark: prepared.spec })));
|
|
311
|
+
});
|
|
312
|
+
const promoted = !target.paragraph.memberIds.includes(spec.id);
|
|
313
|
+
// The logical paragraph already existed before promotion. Reporting it as
|
|
314
|
+
// newly created would incorrectly destroy its ranges when Undo restores rows.
|
|
315
|
+
return {
|
|
316
|
+
createdIds: [],
|
|
317
|
+
removedIds: promoted ? target.paragraph.memberIds : [],
|
|
318
|
+
...(promoted
|
|
319
|
+
? {
|
|
320
|
+
textAnchorMigrations: target.paragraph.members.map((member) => ({
|
|
321
|
+
sourceElementId: member.elementId,
|
|
322
|
+
targetElementId: spec.id,
|
|
323
|
+
sourceLength: member.end - member.start,
|
|
324
|
+
targetOffset: member.start,
|
|
325
|
+
})),
|
|
326
|
+
}
|
|
327
|
+
: {}),
|
|
328
|
+
changedPages: [pageIndex],
|
|
329
|
+
warnings: prepared.warnings,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { PageRect } from "../../types.js";
|
|
2
|
+
import type { PdfElement, PdfTextParagraph } from "../types.js";
|
|
3
|
+
import { type MarkParams, type ObjectRecord } from "./elements.js";
|
|
4
|
+
import { type PageGeometry } from "./geometry.js";
|
|
5
|
+
import { type TextMeasurer } from "./fonts.js";
|
|
6
|
+
import type { Pdfium } from "./pdfium.js";
|
|
7
|
+
/** Persisted inputs, validated at the PDF marked-content boundary. */
|
|
8
|
+
export interface ParagraphSpec extends MarkParams {
|
|
9
|
+
readonly kind: "paragraph";
|
|
10
|
+
readonly rect: PageRect;
|
|
11
|
+
readonly text: string;
|
|
12
|
+
/** Actual native line strings, retained to detect edits made by another tool. */
|
|
13
|
+
readonly lines: readonly string[];
|
|
14
|
+
readonly style: PdfTextParagraph["textStyle"];
|
|
15
|
+
/** First baseline relative to rect.y, in multiples of the current style font size. */
|
|
16
|
+
readonly baselineOffset: number;
|
|
17
|
+
}
|
|
18
|
+
export interface ParagraphTarget {
|
|
19
|
+
readonly paragraph: PdfTextParagraph;
|
|
20
|
+
readonly spec: ParagraphSpec;
|
|
21
|
+
/** Native drawing-order indexes, not logical reading order. */
|
|
22
|
+
readonly indexes: readonly number[];
|
|
23
|
+
}
|
|
24
|
+
export declare function paragraphElement(paragraph: PdfTextParagraph): PdfElement;
|
|
25
|
+
/**
|
|
26
|
+
* Conservative import recognition. Only whole, equally styled native lines
|
|
27
|
+
* with uniform upright matrices and regular left-aligned leading participate.
|
|
28
|
+
* Content-stream order is deliberately irrelevant to reading order.
|
|
29
|
+
*/
|
|
30
|
+
export declare function discoverParagraphs(pdfium: Pdfium, page: number, pageIndex: number, geometry: PageGeometry, records: readonly ObjectRecord[], elements: readonly PdfElement[], textPage: number, measurer: TextMeasurer): ReadonlyMap<string, ParagraphTarget>;
|
|
31
|
+
export declare function intersects(a: PageRect, b: PageRect): boolean;
|