pdfdancer-client-typescript 1.0.12 → 1.0.13

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 (65) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/README.md +1 -1
  3. package/dist/__tests__/e2e/pdf-assertions.d.ts +1 -0
  4. package/dist/__tests__/e2e/pdf-assertions.d.ts.map +1 -1
  5. package/dist/__tests__/e2e/pdf-assertions.js +9 -3
  6. package/dist/__tests__/e2e/pdf-assertions.js.map +1 -1
  7. package/dist/fingerprint.d.ts +12 -0
  8. package/dist/fingerprint.d.ts.map +1 -0
  9. package/dist/fingerprint.js +196 -0
  10. package/dist/fingerprint.js.map +1 -0
  11. package/dist/image-builder.d.ts +4 -2
  12. package/dist/image-builder.d.ts.map +1 -1
  13. package/dist/image-builder.js +12 -3
  14. package/dist/image-builder.js.map +1 -1
  15. package/dist/index.d.ts +2 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +7 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/models.d.ts +75 -8
  20. package/dist/models.d.ts.map +1 -1
  21. package/dist/models.js +179 -21
  22. package/dist/models.js.map +1 -1
  23. package/dist/page-builder.d.ts +24 -0
  24. package/dist/page-builder.d.ts.map +1 -0
  25. package/dist/page-builder.js +107 -0
  26. package/dist/page-builder.js.map +1 -0
  27. package/dist/paragraph-builder.d.ts +48 -54
  28. package/dist/paragraph-builder.d.ts.map +1 -1
  29. package/dist/paragraph-builder.js +408 -135
  30. package/dist/paragraph-builder.js.map +1 -1
  31. package/dist/pdfdancer_v1.d.ts +90 -9
  32. package/dist/pdfdancer_v1.d.ts.map +1 -1
  33. package/dist/pdfdancer_v1.js +535 -50
  34. package/dist/pdfdancer_v1.js.map +1 -1
  35. package/dist/types.d.ts +24 -3
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js +117 -2
  38. package/dist/types.js.map +1 -1
  39. package/docs/openapi.yml +2076 -0
  40. package/fixtures/Showcase.pdf +0 -0
  41. package/package.json +1 -1
  42. package/src/__tests__/e2e/acroform.test.ts +5 -5
  43. package/src/__tests__/e2e/context-manager-showcase.test.ts +267 -0
  44. package/src/__tests__/e2e/form_x_object.test.ts +1 -1
  45. package/src/__tests__/e2e/image-showcase.test.ts +133 -0
  46. package/src/__tests__/e2e/image.test.ts +1 -1
  47. package/src/__tests__/e2e/line-showcase.test.ts +118 -0
  48. package/src/__tests__/e2e/line.test.ts +1 -16
  49. package/src/__tests__/e2e/page-showcase.test.ts +154 -0
  50. package/src/__tests__/e2e/paragraph-showcase.test.ts +523 -0
  51. package/src/__tests__/e2e/paragraph.test.ts +8 -8
  52. package/src/__tests__/e2e/pdf-assertions.ts +10 -3
  53. package/src/__tests__/e2e/pdfdancer-showcase.test.ts +40 -0
  54. package/src/__tests__/e2e/snapshot-showcase.test.ts +158 -0
  55. package/src/__tests__/e2e/snapshot.test.ts +296 -0
  56. package/src/__tests__/fingerprint.test.ts +36 -0
  57. package/src/fingerprint.ts +169 -0
  58. package/src/image-builder.ts +13 -6
  59. package/src/index.ts +6 -1
  60. package/src/models.ts +208 -24
  61. package/src/page-builder.ts +130 -0
  62. package/src/paragraph-builder.ts +517 -159
  63. package/src/pdfdancer_v1.ts +630 -51
  64. package/src/types.ts +145 -2
  65. package/update-api-spec.sh +3 -0
@@ -3,68 +3,172 @@
3
3
  */
4
4
 
5
5
  import {ValidationException} from './exceptions';
6
- import {Color, CommandResult, Font, ObjectRef, Paragraph, Position, TextObjectRef} from './models';
7
- import {PDFDancer} from "./pdfdancer_v1";
6
+ import {
7
+ Color,
8
+ CommandResult,
9
+ Font,
10
+ ObjectRef,
11
+ Paragraph,
12
+ Position,
13
+ StandardFonts,
14
+ TextLine,
15
+ TextObjectRef
16
+ } from './models';
17
+ import {PDFDancer} from './pdfdancer_v1';
18
+
19
+ const DEFAULT_LINE_SPACING_FACTOR = 1.2;
20
+ const DEFAULT_BASE_FONT_SIZE = 12;
8
21
 
9
22
  // 👇 Internal view of PDFDancer methods, not exported
10
23
  interface PDFDancerInternals {
11
- modifyParagraph(objectRefOrPageIndex: ObjectRef, text: string | Paragraph): Promise<CommandResult>;
24
+ modifyParagraph(objectRef: ObjectRef, update: Paragraph | string | null): Promise<CommandResult>;
12
25
 
13
26
  addParagraph(paragraph: Paragraph): Promise<boolean>;
14
27
  }
15
28
 
29
+ const cloneColor = (color?: Color | null): Color | undefined => {
30
+ if (!color) {
31
+ return undefined;
32
+ }
33
+ return new Color(color.r, color.g, color.b, color.a);
34
+ };
35
+
36
+ const clonePosition = (position?: Position): Position | undefined => {
37
+ return position ? position.copy() : undefined;
38
+ };
39
+
40
+ const defaultTextColor = (): Color => new Color(0, 0, 0);
41
+
16
42
  /**
17
43
  * Builder class for constructing Paragraph objects with fluent interface.
44
+ * Aligns with the Python client's ParagraphBuilder behaviour.
18
45
  */
19
46
  export class ParagraphBuilder {
20
- private _paragraph: Paragraph;
21
- private _lineSpacing?: number; // undefined initially, like Python's None
47
+ private readonly _paragraph: Paragraph;
48
+ private readonly _internals: PDFDancerInternals;
49
+
50
+ private _lineSpacingFactor?: number;
22
51
  private _textColor?: Color;
23
52
  private _text?: string;
53
+ private _ttfSource?: Uint8Array | File | string;
24
54
  private _font?: Font;
25
- private _position?: Position; // Track if position was explicitly set
55
+ private _fontExplicitlyChanged = false;
56
+ private _originalParagraphPosition?: Position;
57
+ private _targetObjectRef?: TextObjectRef;
58
+ private _originalFont?: Font;
59
+ private _originalColor?: Color;
60
+ private _positionChanged = false;
61
+ private _pageIndex?: number;
62
+
26
63
  private _pending: Promise<unknown>[] = [];
27
- private _registeringFont: boolean = false;
28
- private _pageIndex: number;
29
- private _internals: PDFDancerInternals;
64
+ private _registeringFont = false;
30
65
 
31
- constructor(private _client: PDFDancer, private objectRefOrPageIndex?: TextObjectRef | number) {
66
+ constructor(private readonly _client: PDFDancer, objectRefOrPageIndex?: TextObjectRef | number) {
32
67
  if (!_client) {
33
68
  throw new ValidationException("Client cannot be null");
34
69
  }
35
70
 
36
- this._pageIndex = objectRefOrPageIndex instanceof ObjectRef ? objectRefOrPageIndex.position.pageIndex! : objectRefOrPageIndex!;
37
71
  this._paragraph = new Paragraph();
38
-
39
- // Cast to the internal interface to get access
40
72
  this._internals = this._client as unknown as PDFDancerInternals;
73
+
74
+ if (objectRefOrPageIndex instanceof TextObjectRef) {
75
+ this.target(objectRefOrPageIndex);
76
+ } else if (typeof objectRefOrPageIndex === 'number') {
77
+ this._pageIndex = objectRefOrPageIndex;
78
+ }
79
+ }
80
+
81
+ static fromObjectRef(client: PDFDancer, objectRef: TextObjectRef): ParagraphBuilder {
82
+ if (!objectRef) {
83
+ throw new ValidationException("Object reference cannot be null");
84
+ }
85
+
86
+ const builder = new ParagraphBuilder(client, objectRef);
87
+ builder.target(objectRef);
88
+ builder.setOriginalParagraphPosition(objectRef.position);
89
+
90
+ if (objectRef.lineSpacings) {
91
+ builder._paragraph.setLineSpacings(objectRef.lineSpacings);
92
+ const [firstSpacing] = objectRef.lineSpacings;
93
+ if (firstSpacing !== undefined) {
94
+ builder._paragraph.lineSpacing = firstSpacing;
95
+ }
96
+ }
97
+
98
+ if (objectRef.fontName && objectRef.fontSize) {
99
+ builder._originalFont = new Font(objectRef.fontName, objectRef.fontSize);
100
+ }
101
+
102
+ if (objectRef.color) {
103
+ builder._originalColor = cloneColor(objectRef.color);
104
+ }
105
+
106
+ if (objectRef.children && objectRef.children.length > 0) {
107
+ objectRef.children.forEach(child => builder.addTextLine(child));
108
+ } else if (objectRef.text) {
109
+ builder._splitText(objectRef.text).forEach(segment => builder.addTextLine(segment));
110
+ }
111
+
112
+ return builder;
41
113
  }
42
114
 
43
- /**
44
- * Set the text content for the paragraph.
45
- */
46
- replace(text: string, color?: Color): ParagraphBuilder {
115
+ setFontExplicitlyChanged(changed: boolean): void {
116
+ this._fontExplicitlyChanged = !!changed;
117
+ }
118
+
119
+ setOriginalParagraphPosition(position?: Position): void {
120
+ this._originalParagraphPosition = clonePosition(position);
121
+ if (position && !this._paragraph.getPosition()) {
122
+ this._paragraph.setPosition(clonePosition(position)!);
123
+ }
124
+ if (position?.pageIndex !== undefined) {
125
+ this._pageIndex = position.pageIndex;
126
+ }
127
+ }
128
+
129
+ target(objectRef: ObjectRef): this {
130
+ if (!objectRef) {
131
+ throw new ValidationException("Object reference cannot be null");
132
+ }
133
+ this._targetObjectRef = objectRef as TextObjectRef;
134
+ if (objectRef.position) {
135
+ this.setOriginalParagraphPosition(objectRef.position);
136
+ }
137
+ if (objectRef.position?.pageIndex !== undefined) {
138
+ this._pageIndex = objectRef.position.pageIndex;
139
+ }
140
+ return this;
141
+ }
142
+
143
+ onlyTextChanged(): boolean {
144
+ return (
145
+ this._text !== undefined &&
146
+ this._textColor === undefined &&
147
+ this._ttfSource === undefined &&
148
+ (this._font === undefined || !this._fontExplicitlyChanged) &&
149
+ this._lineSpacingFactor === undefined
150
+ );
151
+ }
152
+
153
+ replace(text: string, color?: Color): this {
154
+ return this.text(text, color);
155
+ }
156
+
157
+ text(text: string, color?: Color): this {
47
158
  if (text === null || text === undefined) {
48
159
  throw new ValidationException("Text cannot be null");
49
160
  }
50
- if (!text.trim()) {
51
- throw new ValidationException("Text cannot be empty");
52
- }
53
161
 
54
162
  this._text = text;
55
163
  if (color) {
56
- this._textColor = color;
164
+ this.color(color);
57
165
  }
58
-
59
166
  return this;
60
167
  }
61
168
 
62
- /**
63
- * Set the font for the paragraph using an existing Font object.
64
- */
65
- font(font: Font): ParagraphBuilder;
66
- font(fontName: string, fontSize: number): ParagraphBuilder;
67
- font(fontOrName: Font | string, fontSize?: number): ParagraphBuilder {
169
+ font(font: Font): this;
170
+ font(fontName: string, fontSize: number): this;
171
+ font(fontOrName: Font | string, fontSize?: number): this {
68
172
  if (fontOrName instanceof Font) {
69
173
  this._font = fontOrName;
70
174
  } else {
@@ -77,210 +181,464 @@ export class ParagraphBuilder {
77
181
  this._font = new Font(fontOrName, fontSize);
78
182
  }
79
183
 
184
+ this._fontExplicitlyChanged = true;
80
185
  return this;
81
186
  }
82
187
 
83
- /**
84
- * Set the font for the paragraph using a TTF file.
85
- */
86
188
  fontFile(ttfFile: Uint8Array | File | string, fontSize: number): this {
87
- if (!ttfFile) throw new ValidationException("TTF file cannot be null");
189
+ if (!ttfFile) {
190
+ throw new ValidationException("TTF file cannot be null");
191
+ }
88
192
  if (fontSize <= 0) {
89
193
  throw new ValidationException(`Font size must be positive, got ${fontSize}`);
90
194
  }
91
195
 
196
+ this._ttfSource = ttfFile;
92
197
  this._registeringFont = true;
93
- const job = this._registerTtf(ttfFile, fontSize).then(font => {
94
- this._font = font;
95
- });
198
+ const job = this._registerTtf(ttfFile, fontSize)
199
+ .then(font => {
200
+ this._font = font;
201
+ this._fontExplicitlyChanged = true;
202
+ })
203
+ .finally(() => {
204
+ this._registeringFont = false;
205
+ });
96
206
 
97
207
  this._pending.push(job);
98
208
  return this;
99
209
  }
100
210
 
101
- /**
102
- * Set the line spacing for the paragraph.
103
- */
104
- lineSpacing(spacing: number): ParagraphBuilder {
211
+ lineSpacing(spacing: number): this {
105
212
  if (spacing <= 0) {
106
213
  throw new ValidationException(`Line spacing must be positive, got ${spacing}`);
107
214
  }
108
-
109
- this._lineSpacing = spacing;
215
+ this._lineSpacingFactor = spacing;
110
216
  return this;
111
217
  }
112
218
 
113
- /**
114
- * Set the text color for the paragraph.
115
- */
116
- color(color: Color): ParagraphBuilder {
219
+ color(color: Color): this {
117
220
  if (!color) {
118
221
  throw new ValidationException("Color cannot be null");
119
222
  }
120
-
121
223
  this._textColor = color;
122
224
  return this;
123
225
  }
124
226
 
125
- /**
126
- * Set the position for the paragraph.
127
- */
128
- moveTo(x: number, y: number): ParagraphBuilder {
227
+ moveTo(x: number, y: number): this {
129
228
  if (x === null || x === undefined || y === null || y === undefined) {
130
229
  throw new ValidationException("Coordinates cannot be null or undefined");
131
230
  }
132
231
 
133
- this._position = Position.atPageCoordinates(this._pageIndex, x, y);
134
- this._paragraph.setPosition(this._position);
232
+ let position = this._paragraph.getPosition();
233
+ if (!position && this._targetObjectRef?.position) {
234
+ position = clonePosition(this._targetObjectRef.position);
235
+ }
236
+
237
+ const pageIndex = position?.pageIndex ?? this._pageIndex;
238
+ if (pageIndex === undefined) {
239
+ throw new ValidationException("Paragraph position must include a page index to move");
240
+ }
241
+
242
+ this._paragraph.setPosition(Position.atPageCoordinates(pageIndex, x, y));
243
+ this._positionChanged = true;
135
244
  return this;
136
245
  }
137
246
 
138
- /**
139
- * Build and return the final Paragraph object.
140
- */
141
- private build(): Paragraph {
142
- // Validate required fields
143
- if (!this._text) {
144
- throw new ValidationException("Text must be set before building paragraph");
247
+ atPosition(position: Position): this {
248
+ if (!position) {
249
+ throw new ValidationException("Position cannot be null");
250
+ }
251
+ this._paragraph.setPosition(clonePosition(position)!);
252
+ this._positionChanged = true;
253
+ if (position.pageIndex !== undefined) {
254
+ this._pageIndex = position.pageIndex;
145
255
  }
146
- // Set paragraph properties
147
- this._paragraph.font = this._font;
148
- this._paragraph.color = this._textColor ?? new Color(0, 0, 0);
149
- this._paragraph.lineSpacing = this._lineSpacing ?? 1.2; // Default 1.2 like Python
256
+ return this;
257
+ }
150
258
 
151
- // Process text into lines
152
- this._paragraph.textLines = this._processTextLines(this._text);
259
+ at(x: number, y: number): this;
260
+ at(pageIndex: number, x: number, y: number): this;
261
+ at(pageIndexOrX: number, xOrY: number, maybeY?: number): this {
262
+ if (maybeY === undefined) {
263
+ const pageIndex = this._pageIndex ?? this._paragraph.getPosition()?.pageIndex;
264
+ if (pageIndex === undefined) {
265
+ throw new ValidationException("Page index must be provided before calling at(x, y)");
266
+ }
267
+ return this._setPosition(pageIndex, pageIndexOrX, xOrY);
268
+ }
153
269
 
154
- return this._paragraph;
270
+ return this._setPosition(pageIndexOrX, xOrY, maybeY);
155
271
  }
156
272
 
157
- // Python-style getter methods that preserve original values if not explicitly set
158
- private _getLineSpacing(originalRef: TextObjectRef): number {
159
- if (this._lineSpacing !== undefined) {
160
- return this._lineSpacing;
161
- } else if (originalRef.lineSpacings && originalRef.lineSpacings.length > 0) {
162
- // Calculate average like Python does
163
- const sum = originalRef.lineSpacings.reduce((a, b) => a + b, 0);
164
- return sum / originalRef.lineSpacings.length;
165
- } else {
166
- return 1.2; // DEFAULT_LINE_SPACING
273
+ private _setPosition(pageIndex: number, x: number, y: number): this {
274
+ this._pageIndex = pageIndex;
275
+ return this.atPosition(Position.atPageCoordinates(pageIndex, x, y));
276
+ }
277
+
278
+ addTextLine(textLine: TextLine | TextObjectRef | string): this {
279
+ this._paragraph.addLine(this._coerceTextLine(textLine));
280
+ return this;
281
+ }
282
+
283
+ getText(): string | undefined {
284
+ return this._text;
285
+ }
286
+
287
+ async add(): Promise<boolean> {
288
+ await this._prepareAsync();
289
+ if (this._targetObjectRef) {
290
+ throw new ValidationException("Target object reference provided; use modify() for updates");
167
291
  }
292
+ const paragraph = this._finalizeParagraph();
293
+ return this._internals.addParagraph(paragraph);
168
294
  }
169
295
 
170
- private _getFont(originalRef: TextObjectRef): Font {
171
- if (this._font) {
172
- return this._font;
173
- } else if (originalRef.fontName && originalRef.fontSize) {
174
- return new Font(originalRef.fontName, originalRef.fontSize);
175
- } else {
176
- throw new ValidationException("Font is required");
296
+ async modify(objectRef?: ObjectRef): Promise<CommandResult> {
297
+ await this._prepareAsync();
298
+ const target = (objectRef as TextObjectRef) ?? this._targetObjectRef;
299
+ if (!target) {
300
+ throw new ValidationException("Object reference must be provided to modify a paragraph");
301
+ }
302
+
303
+ if (this.onlyTextChanged()) {
304
+ const result = await this._internals.modifyParagraph(target, this._text ?? '');
305
+ return this._withWarning(result);
306
+ }
307
+
308
+ const paragraph = this._finalizeParagraph();
309
+ const result = await this._internals.modifyParagraph(target, paragraph);
310
+ return this._withWarning(result);
311
+ }
312
+
313
+ async apply(): Promise<boolean | CommandResult> {
314
+ await this._prepareAsync();
315
+ if (this._targetObjectRef) {
316
+ return this.modify(this._targetObjectRef);
177
317
  }
318
+ return this.add();
178
319
  }
179
320
 
180
- private _getColor(originalRef: TextObjectRef): Color {
181
- if (this._textColor) {
182
- return this._textColor;
183
- } else if (originalRef.color) {
184
- return originalRef.color;
185
- } else {
186
- return new Color(0, 0, 0); // DEFAULT_COLOR
321
+ private async _prepareAsync(): Promise<void> {
322
+ if (this._pending.length) {
323
+ await Promise.all(this._pending);
324
+ this._pending = [];
325
+ }
326
+
327
+ if (this._registeringFont) {
328
+ throw new ValidationException("Font registration is not complete");
187
329
  }
188
330
  }
189
331
 
190
- /**
191
- * Register a TTF font with the client and return a Font object.
192
- */
193
- private async _registerTtf(ttfFile: Uint8Array | File | string, fontSize: number): Promise<Font> {
194
- try {
195
- const fontName = await this._client.registerFont(ttfFile);
196
- return new Font(fontName, fontSize);
197
- } catch (error) {
198
- throw new ValidationException(`Failed to register font file: ${error}`);
332
+ private _withWarning(result: CommandResult): CommandResult {
333
+ if (result && result.warning) {
334
+ process.stderr.write(`WARNING: ${result.warning}\n`);
199
335
  }
336
+ return result;
200
337
  }
201
338
 
202
- /**
203
- * Process text into lines for the paragraph.
204
- * This is a simplified version - the full implementation would handle
205
- * word wrapping, line breaks, and other text formatting based on the font
206
- * and paragraph width.
207
- */
208
- private _processTextLines(text: string): string[] {
209
- // Handle escaped newlines (\\n) as actual newlines
210
- const processedText = text.replace(/\\\\n/g, '\n');
339
+ private _finalizeParagraph(): Paragraph {
340
+ const position = this._paragraph.getPosition();
341
+ if (!position) {
342
+ throw new ValidationException("Paragraph position is null, you need to specify a position for the new paragraph, using .at(x,y)");
343
+ }
211
344
 
212
- // Simple implementation - split on newlines
213
- // In the full version, this would implement proper text layout
214
- let lines = processedText.split('\n');
345
+ if (!this._targetObjectRef && !this._font && !this._paragraph.font) {
346
+ throw new ValidationException("Font must be set before building paragraph");
347
+ }
215
348
 
216
- // Remove empty lines at the end but preserve intentional line breaks
217
- while (lines.length > 0 && !lines[lines.length - 1].trim()) {
218
- lines.pop();
349
+ if (this._text !== undefined) {
350
+ this._finalizeLinesFromText();
351
+ } else if (!this._paragraph.textLines || this._paragraph.textLines.length === 0) {
352
+ throw new ValidationException("Either text must be provided or existing lines supplied");
353
+ } else {
354
+ this._finalizeExistingLines();
219
355
  }
220
356
 
221
- // Ensure at least one line
222
- if (lines.length === 0) {
223
- lines = [''];
357
+ this._repositionLines();
358
+
359
+ const shouldSkipLines = (
360
+ this._positionChanged &&
361
+ this._text === undefined &&
362
+ this._textColor === undefined &&
363
+ (this._font === undefined || !this._fontExplicitlyChanged) &&
364
+ this._lineSpacingFactor === undefined
365
+ );
366
+
367
+ if (shouldSkipLines) {
368
+ this._paragraph.textLines = undefined;
369
+ this._paragraph.setLineSpacings(null);
224
370
  }
225
371
 
226
- return lines;
372
+ let finalFont = this._font ?? this._paragraph.font ?? this._originalFont;
373
+ if (!finalFont) {
374
+ finalFont = new Font(StandardFonts.HELVETICA, DEFAULT_BASE_FONT_SIZE);
375
+ }
376
+ this._paragraph.font = finalFont;
377
+
378
+ let finalColor: Color | undefined;
379
+ if (this._textColor) {
380
+ finalColor = cloneColor(this._textColor);
381
+ } else if (this._text !== undefined) {
382
+ finalColor = cloneColor(this._originalColor) ?? defaultTextColor();
383
+ } else {
384
+ finalColor = cloneColor(this._originalColor);
385
+ }
386
+ this._paragraph.color = finalColor;
387
+
388
+ return this._paragraph;
227
389
  }
228
390
 
229
- async apply(): Promise<boolean | CommandResult> {
230
- // Wait for all deferred operations (e.g., fontFile, images, etc.)
231
- if (this._pending.length) {
232
- await Promise.all(this._pending);
233
- this._pending = []; // reset if builder is reusable
391
+ private _finalizeLinesFromText(): void {
392
+ const baseFont = this._font ?? this._originalFont;
393
+ const baseColor = this._textColor ?? cloneColor(this._originalColor) ?? defaultTextColor();
394
+
395
+ let spacing: number;
396
+ if (this._lineSpacingFactor !== undefined) {
397
+ spacing = this._lineSpacingFactor;
398
+ } else {
399
+ const existingSpacings = this._paragraph.getLineSpacings();
400
+ if (existingSpacings && existingSpacings.length > 0) {
401
+ spacing = existingSpacings[0];
402
+ } else if (this._paragraph.lineSpacing !== undefined && this._paragraph.lineSpacing !== null) {
403
+ spacing = this._paragraph.lineSpacing;
404
+ } else {
405
+ spacing = DEFAULT_LINE_SPACING_FACTOR;
406
+ }
234
407
  }
235
408
 
236
- if (this._registeringFont && !this._font) {
237
- throw new ValidationException("Font registration is not complete");
409
+ this._paragraph.clearLines();
410
+ const lines: TextLine[] = [];
411
+
412
+ this._splitText(this._text ?? '').forEach((lineText, index) => {
413
+ const linePosition = this._calculateLinePosition(index, spacing);
414
+ lines.push(new TextLine(linePosition, baseFont, cloneColor(baseColor), spacing, lineText));
415
+ });
416
+
417
+ this._paragraph.setLines(lines);
418
+ if (lines.length > 1) {
419
+ this._paragraph.setLineSpacings(Array(lines.length - 1).fill(spacing));
420
+ } else {
421
+ this._paragraph.setLineSpacings(null);
238
422
  }
423
+ this._paragraph.lineSpacing = spacing;
424
+ }
425
+
426
+ private _finalizeExistingLines(): void {
427
+ const lines = this._paragraph.getLines();
428
+ const spacingOverride = this._lineSpacingFactor;
429
+ let spacingForCalc = spacingOverride;
239
430
 
240
- if (this.objectRefOrPageIndex instanceof TextObjectRef) {
241
- // Modifying existing paragraph - match Python's ParagraphEdit.apply() logic
242
- const originalRef = this.objectRefOrPageIndex;
431
+ if (spacingForCalc === undefined) {
432
+ const existingSpacings = this._paragraph.getLineSpacings();
433
+ if (existingSpacings && existingSpacings.length > 0) {
434
+ spacingForCalc = existingSpacings[0];
435
+ }
436
+ }
437
+ if (spacingForCalc === undefined) {
438
+ spacingForCalc = this._paragraph.lineSpacing ?? DEFAULT_LINE_SPACING_FACTOR;
439
+ }
243
440
 
244
- // Python logic: if ONLY text is being changed (all other properties are None), use simple text modification
245
- if (this._position === undefined &&
246
- this._lineSpacing === undefined &&
247
- this._font === undefined &&
248
- this._textColor === undefined) {
249
- // Simple text-only modification
250
- const result = await this._internals.modifyParagraph(originalRef, this._text!);
251
- if (result.warning) {
252
- process.stderr.write(`WARNING: ${result.warning}\n`);
441
+ const updatedLines: TextLine[] = [];
442
+ lines.forEach((line, index) => {
443
+ if (line instanceof TextLine) {
444
+ if (spacingOverride !== undefined) {
445
+ line.lineSpacing = spacingOverride;
446
+ }
447
+ if (this._textColor) {
448
+ line.color = cloneColor(this._textColor);
449
+ }
450
+ if (this._font && this._fontExplicitlyChanged) {
451
+ line.font = this._font;
253
452
  }
254
- return result;
453
+ updatedLines.push(line);
255
454
  } else {
256
- // Full paragraph modification - build new paragraph using getter methods to preserve original values
257
- const newParagraph = new Paragraph();
258
- newParagraph.position = this._position ?? originalRef.position;
259
- newParagraph.lineSpacing = this._getLineSpacing(originalRef);
260
- newParagraph.font = this._getFont(originalRef);
261
- newParagraph.textLines = this._text ? this._processTextLines(this._text) : this._processTextLines(originalRef.text!);
262
- newParagraph.color = this._getColor(originalRef);
263
-
264
- const result = await this._internals.modifyParagraph(originalRef, newParagraph);
265
- if (result.warning) {
266
- process.stderr.write(`WARNING: ${result.warning}\n`);
455
+ const linePosition = this._calculateLinePosition(index, spacingForCalc!);
456
+ updatedLines.push(new TextLine(
457
+ linePosition,
458
+ this._font ?? this._originalFont,
459
+ this._textColor ?? cloneColor(this._originalColor) ?? defaultTextColor(),
460
+ spacingOverride ?? spacingForCalc!,
461
+ String(line)
462
+ ));
463
+ }
464
+ });
465
+
466
+ this._paragraph.setLines(updatedLines);
467
+
468
+ if (spacingOverride !== undefined) {
469
+ if (updatedLines.length > 1) {
470
+ this._paragraph.setLineSpacings(Array(updatedLines.length - 1).fill(spacingOverride));
471
+ } else {
472
+ this._paragraph.setLineSpacings(null);
473
+ }
474
+ this._paragraph.lineSpacing = spacingOverride;
475
+ }
476
+ }
477
+
478
+ private _repositionLines(): void {
479
+ if (this._text !== undefined) {
480
+ return;
481
+ }
482
+
483
+ const paragraphPos = this._paragraph.getPosition();
484
+ const lines = this._paragraph.textLines;
485
+ if (!paragraphPos || !lines || lines.length === 0) {
486
+ return;
487
+ }
488
+
489
+ let basePosition = this._originalParagraphPosition;
490
+ if (!basePosition) {
491
+ for (const line of lines) {
492
+ if (line instanceof TextLine && line.position) {
493
+ basePosition = line.position;
494
+ break;
267
495
  }
268
- return result;
269
496
  }
270
- } else {
271
- // Adding new paragraph
272
- let paragraph = this.build();
273
- return await this._internals.addParagraph(paragraph);
274
497
  }
498
+
499
+ if (!basePosition) {
500
+ return;
501
+ }
502
+
503
+ const targetX = paragraphPos.getX();
504
+ const targetY = paragraphPos.getY();
505
+ const baseX = basePosition.getX();
506
+ const baseY = basePosition.getY();
507
+
508
+ if (targetX === undefined || targetY === undefined || baseX === undefined || baseY === undefined) {
509
+ return;
510
+ }
511
+
512
+ const dx = targetX - baseX;
513
+ const dy = targetY - baseY;
514
+ if (dx === 0 && dy === 0) {
515
+ return;
516
+ }
517
+
518
+ lines.forEach(line => {
519
+ if (line instanceof TextLine && line.position) {
520
+ const currentX = line.position.getX();
521
+ const currentY = line.position.getY();
522
+ if (currentX === undefined || currentY === undefined) {
523
+ return;
524
+ }
525
+ const updatedPosition = line.position.copy();
526
+ updatedPosition.atCoordinates({x: currentX + dx, y: currentY + dy});
527
+ line.setPosition(updatedPosition);
528
+ }
529
+ });
275
530
  }
276
531
 
277
- text(text: string) {
278
- this._text = text;
279
- return this;
532
+ private _coerceTextLine(source: TextLine | TextObjectRef | string): TextLine {
533
+ if (source instanceof TextLine) {
534
+ return source;
535
+ }
536
+
537
+ if (source instanceof TextObjectRef) {
538
+ let font: Font | undefined;
539
+ if (source.fontName && source.fontSize) {
540
+ font = new Font(source.fontName, source.fontSize);
541
+ } else if (source.children) {
542
+ for (const child of source.children) {
543
+ if (child.fontName && child.fontSize) {
544
+ font = new Font(child.fontName, child.fontSize);
545
+ break;
546
+ }
547
+ }
548
+ }
549
+ if (!font) {
550
+ font = this._originalFont;
551
+ }
552
+
553
+ let spacing = this._lineSpacingFactor;
554
+ if (spacing === undefined && source.lineSpacings && source.lineSpacings.length > 0) {
555
+ spacing = source.lineSpacings[0];
556
+ }
557
+ if (spacing === undefined) {
558
+ spacing = this._paragraph.lineSpacing ?? DEFAULT_LINE_SPACING_FACTOR;
559
+ }
560
+
561
+ const color = source.color ?? this._originalColor;
562
+
563
+ if (!this._originalFont && font) {
564
+ this._originalFont = font;
565
+ }
566
+ if (!this._originalColor && color) {
567
+ this._originalColor = color;
568
+ }
569
+
570
+ return new TextLine(
571
+ clonePosition(source.position),
572
+ font,
573
+ cloneColor(color),
574
+ spacing,
575
+ source.text ?? ''
576
+ );
577
+ }
578
+
579
+ const currentIndex = this._paragraph.getLines().length;
580
+ const spacing = this._lineSpacingFactor ?? this._paragraph.lineSpacing ?? DEFAULT_LINE_SPACING_FACTOR;
581
+ const linePosition = this._calculateLinePosition(currentIndex, spacing);
582
+
583
+ return new TextLine(
584
+ linePosition,
585
+ this._font ?? this._originalFont,
586
+ this._textColor ?? cloneColor(this._originalColor) ?? defaultTextColor(),
587
+ spacing,
588
+ source
589
+ );
590
+ }
591
+
592
+ private _splitText(text: string): string[] {
593
+ const processed = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\\n/g, '\n');
594
+ const parts = processed.split('\n');
595
+ while (parts.length > 0 && parts[parts.length - 1] === '') {
596
+ parts.pop();
597
+ }
598
+ if (parts.length === 0) {
599
+ parts.push('');
600
+ }
601
+ return parts;
602
+ }
603
+
604
+ private _calculateLinePosition(lineIndex: number, spacingFactor: number): Position | undefined {
605
+ const paragraphPosition = this._paragraph.getPosition();
606
+ if (!paragraphPosition) {
607
+ return undefined;
608
+ }
609
+
610
+ const pageIndex = paragraphPosition.pageIndex;
611
+ const baseX = paragraphPosition.getX();
612
+ const baseY = paragraphPosition.getY();
613
+ if (pageIndex === undefined || baseX === undefined || baseY === undefined) {
614
+ return undefined;
615
+ }
616
+
617
+ const offset = lineIndex * this._calculateBaselineDistance(spacingFactor);
618
+ return Position.atPageCoordinates(pageIndex, baseX, baseY + offset);
280
619
  }
281
620
 
282
- at(x: number, y: number) {
283
- return this.moveTo(x, y);
621
+ private _calculateBaselineDistance(spacingFactor: number): number {
622
+ const factor = spacingFactor > 0 ? spacingFactor : DEFAULT_LINE_SPACING_FACTOR;
623
+ return this._baselineFontSize() * factor;
284
624
  }
285
625
 
626
+ private _baselineFontSize(): number {
627
+ if (this._font?.size) {
628
+ return this._font.size;
629
+ }
630
+ if (this._originalFont?.size) {
631
+ return this._originalFont.size;
632
+ }
633
+ return DEFAULT_BASE_FONT_SIZE;
634
+ }
635
+
636
+ private async _registerTtf(ttfFile: Uint8Array | File | string, fontSize: number): Promise<Font> {
637
+ try {
638
+ const fontName = await this._client.registerFont(ttfFile);
639
+ return new Font(fontName, fontSize);
640
+ } catch (error: any) {
641
+ throw new ValidationException(`Failed to register font file: ${error}`);
642
+ }
643
+ }
286
644
  }